package chat.simplex.common.model

import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.common.model.MsgFilter.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.item.contentModerationPostLink
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrationToDeviceState
import chat.simplex.common.views.migration.MigrationToState
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlin.collections.removeAll as remAll
import kotlinx.datetime.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
import java.io.Closeable
import java.io.File
import java.net.URI
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.*
import java.util.concurrent.atomic.AtomicLong
import kotlin.collections.ArrayList
import kotlin.random.Random
import kotlin.time.*

object ConnectProgressManager {
  private val connectInProgress = mutableStateOf<String?>(null)
  private val connectProgressByTimeout = mutableStateOf(false)
  private var onCancel: (() -> Unit)? = null

  private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

  fun startConnectProgress(text: String, onCancel: (() -> Unit)? = null) {
    connectInProgress.value = text
    this.onCancel = onCancel
    coroutineScope.launch {
      delay(1000)
      connectProgressByTimeout.value = connectInProgress.value != null
    }
  }

  fun stopConnectProgress() {
    connectInProgress.value = null
    onCancel = null
    connectProgressByTimeout.value = false
  }

  fun cancelConnectProgress() {
    onCancel?.invoke()
    stopConnectProgress()
  }

  val showConnectProgress: String? get() =
    if (connectProgressByTimeout.value) connectInProgress.value else null
}

val connectProgressManager = ConnectProgressManager

/*
 * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
 * */
@Stable
object ChatModel {
  val controller: ChatController = ChatController
  val setDeliveryReceipts = mutableStateOf(false)
  val currentUser = mutableStateOf<User?>(null)
  val users = mutableStateListOf<UserInfo>()
  val localUserCreated = mutableStateOf<Boolean?>(null)
  val chatRunning = mutableStateOf<Boolean?>(null)
  val chatDbChanged = mutableStateOf<Boolean>(false)
  val chatDbEncrypted = mutableStateOf<Boolean?>(false)
  val chatDbStatus = mutableStateOf<DBMigrationResult?>(null)
  val ctrlInitInProgress = mutableStateOf(false)
  val dbMigrationInProgress = mutableStateOf(false)
  val incompleteInitializedDbRemoved = mutableStateOf(false)
  // map of connections network statuses, key is agent connection id
  val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
  val switchingUsersAndHosts = mutableStateOf(false)

  // current chat
  val chatId = mutableStateOf<String?>(null)
  val openAroundItemId: MutableState<Long?> = mutableStateOf(null)
  val chatsContext = ChatsContext(null)
  val secondaryChatsContext = mutableStateOf<ChatsContext?>(null)
  // declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions
  val chats: State<List<Chat>> = chatsContext.chats
  // rhId, chatId
  val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
  val groupMembers = mutableStateOf<List<GroupMember>>(emptyList())
  val groupMembersIndexes = mutableStateOf<Map<Long, Int>>(emptyMap())
  val membersLoaded = mutableStateOf(false)

  // Chat Tags
  val userTags = mutableStateOf(emptyList<ChatTag>())
  val activeChatTagFilter = mutableStateOf<ActiveFilter?>(null)
  val presetTags = mutableStateMapOf<PresetTagKind, Int>()
  val unreadTags = mutableStateMapOf<Long, Int>()

  // false: default placement, true: floating window.
  // Used for deciding to add terminal items on main thread or not. Floating means appPrefs.terminalAlwaysVisible
  var terminalsVisible = setOf<Boolean>()
  val terminalItems = mutableStateOf<List<TerminalItem>>(listOf())
  val userAddress = mutableStateOf<UserContactLinkRec?>(null)
  val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)

  // set when app opened from external intent
  val clearOverlays = mutableStateOf<Boolean>(false)

  // Only needed during onboarding when user skipped password setup (left as random password)
  val desktopOnboardingRandomPassword = mutableStateOf(false)

  // set when app is opened via contact or invitation URI (rhId, uri)
  val appOpenUrl = mutableStateOf<Pair<Long?, String>?>(null)
  val appOpenUrlConnecting = mutableStateOf<Boolean>(false)

  // Needed to check for bottom nav bar and to apply or not navigation bar color on Android
  val newChatSheetVisible = mutableStateOf(false)

  // Needed to apply black color to left/right cutout area on Android
  val fullscreenGalleryVisible = mutableStateOf(false)

  // preferences
  val notificationPreviewMode by lazy {
    mutableStateOf(
      try {
        NotificationPreviewMode.valueOf(controller.appPrefs.notificationPreviewMode.get()!!)
      } catch (e: Exception) {
        NotificationPreviewMode.default
      }
    )
  }
  val showAuthScreen by lazy { mutableStateOf(ChatController.appPrefs.performLA.get()) }
  val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
  val showChatPreviews by lazy { mutableStateOf(ChatController.appPrefs.privacyShowChatPreviews.get()) }

  // current WebRTC call
  val callManager = CallManager(this)
  val callInvitations = mutableStateMapOf<String, RcvCallInvitation>()
  val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
  val activeCall = mutableStateOf<Call?>(null)
  val activeCallViewIsVisible = mutableStateOf<Boolean>(false)
  val activeCallViewIsCollapsed = mutableStateOf<Boolean>(false)
  val callCommand = mutableStateListOf<WCallCommand>()
  val showCallView = mutableStateOf(false)
  val switchingCall = mutableStateOf(false)

  // currently showing invitation
  val showingInvitation = mutableStateOf(null as ShowingInvitation?)

  val migrationState: MutableState<MigrationToState?> by lazy { mutableStateOf(MigrationToDeviceState.makeMigrationState()) }

  var draft = mutableStateOf(null as ComposeState?)
  var draftChatId = mutableStateOf(null as String?)

  // working with external intents or internal forwarding of chat items
  val sharedContent = mutableStateOf(null as SharedContent?)

  val filesToDelete = mutableSetOf<File>()
  val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) }

  val clipboardHasText = mutableStateOf(false)
  val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true))

  val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty)

  val updatingProgress = mutableStateOf(null as Float?)
  var updatingRequest: Closeable? = null

  val changingActiveUserMutex: Mutex = Mutex()

  val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
  fun desktopNoUserNoRemote(): Boolean = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null

  // remote controller
  val remoteHosts = mutableStateListOf<RemoteHostInfo>()
  val currentRemoteHost = mutableStateOf<RemoteHostInfo?>(null)
  val remoteHostId: Long? @Composable get() = remember { currentRemoteHost }.value?.remoteHostId
  fun remoteHostId(): Long? = currentRemoteHost.value?.remoteHostId
  val remoteHostPairing = mutableStateOf<Pair<RemoteHostInfo?, RemoteHostSessionState>?>(null)
  val remoteCtrlSession = mutableStateOf<RemoteCtrlSession?>(null)

  val processedCriticalError: ProcessedErrors<AgentErrorType.CRITICAL> = ProcessedErrors(60_000)
  val processedInternalError: ProcessedErrors<AgentErrorType.INTERNAL> = ProcessedErrors(20_000)

  // return true if you handled the click
  var centerPanelBackgroundClickHandler: (() -> Boolean)? = null

  fun addressShortLinkDataSet(): Boolean = userAddress.value?.shortLinkDataSet ?: true

  fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
    currentUser.value
  } else {
    users.firstOrNull { it.user.userId == userId }?.user
  }

  private fun getUserIndex(user: User): Int =
    users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == user.remoteHostId }

  fun updateUser(user: User) {
    val i = getUserIndex(user)
    if (i != -1) {
      users[i] = users[i].copy(user = user)
    }
    if (currentUser.value?.userId == user.userId) {
      currentUser.value = user
    }
  }

  fun removeUser(user: User) {
    val i = getUserIndex(user)
    if (i != -1) {
      users.removeAt(i)
    }
  }

  fun updateChatTags(rhId: Long?) {
    val newPresetTags = mutableMapOf<PresetTagKind, Int>()
    val newUnreadTags = mutableMapOf<Long, Int>()

    for (chat in chats.value.filter { it.remoteHostId == rhId }) {
      for (tag in PresetTagKind.entries) {
        if (presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)) {
          newPresetTags[tag] = (newPresetTags[tag] ?: 0) + 1
        }
      }
      if (chat.unreadTag) {
        val chatTags: List<Long> = when (val cInfo = chat.chatInfo) {
          is ChatInfo.Direct -> cInfo.contact.chatTags
          is ChatInfo.Group -> cInfo.groupInfo.chatTags
          else -> emptyList()
        }
        chatTags.forEach { tag ->
          newUnreadTags[tag] = (newUnreadTags[tag] ?: 0) + 1
        }
      }
    }

    presetTags.clear()
    presetTags.putAll(newPresetTags)
    unreadTags.clear()
    unreadTags.putAll(newUnreadTags)

    clearActiveChatFilterIfNeeded()
  }

  fun updateChatFavorite(favorite: Boolean, wasFavorite: Boolean) {
    val count = presetTags[PresetTagKind.FAVORITES]

    if (favorite && !wasFavorite) {
      presetTags[PresetTagKind.FAVORITES] = (count ?: 0) + 1
    } else if (!favorite && wasFavorite && count != null) {
      presetTags[PresetTagKind.FAVORITES] = maxOf(0, count - 1)
      clearActiveChatFilterIfNeeded()
    }
  }

  private fun addPresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) {
    for (tag in PresetTagKind.entries) {
      if (presetTagMatchesChat(tag, chatInfo, chatStats)) {
        presetTags[tag] = (presetTags[tag] ?: 0) + 1
      }
    }
  }

  fun removePresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) {
    for (tag in PresetTagKind.entries) {
      if (presetTagMatchesChat(tag, chatInfo, chatStats)) {
        val count = presetTags[tag]
        if (count != null) {
          presetTags[tag] = maxOf(0, count - 1)
        }
      }
    }
    clearActiveChatFilterIfNeeded()
  }

  fun moveChatTagUnread(chat: Chat, oldTags: List<Long>?, newTags: List<Long>) {
    if (chat.unreadTag) {
      oldTags?.forEach { t ->
        val oldCount = unreadTags[t]
        if (oldCount != null) {
          unreadTags[t] = maxOf(0, oldCount - 1)
        }
      }

      newTags.forEach { t ->
        unreadTags[t] = (unreadTags[t] ?: 0) + 1
      }
    }
  }
  // TODO pass rhId?
  fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id }
  fun getContactChat(contactId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
  fun getGroupChat(groupId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId }

  fun populateGroupMembersIndexes() {
    groupMembersIndexes.value = emptyMap()
    val gmIndexes = groupMembersIndexes.value.toMutableMap()
    groupMembers.value.forEachIndexed { i, member ->
      gmIndexes[member.groupMemberId] = i
    }
    groupMembersIndexes.value = gmIndexes
  }

  fun getGroupMember(groupMemberId: Long): GroupMember? {
    val memberIndex = groupMembersIndexes.value[groupMemberId]
    return if (memberIndex != null) {
      groupMembers.value[memberIndex]
    } else {
      null
    }
  }

  class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) {
    val chats = mutableStateOf(SnapshotStateList<Chat>())
    /** if you modify the items by adding/removing them, use helpers methods like [addToChatItems], [removeLastChatItems], [removeAllAndNotify], [clearAndNotify] and so on.
     * If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages].
     * If you use api call to get the items, use just [add] instead of [addToChatItems].
     * Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */
    val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
    // set listener here that will be notified on every add/delete of a chat item
    val chatState = ActiveChatState()

    fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null
    fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id }
    private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId }

    val contentTag: MsgContentTag? =
      when (secondaryContextFilter) {
        null -> null
        is SecondaryContextFilter.GroupChatScopeContext -> null
        is SecondaryContextFilter.MsgContentTagContext -> secondaryContextFilter.contentTag
      }

    val groupScopeInfo: GroupChatScopeInfo? =
      when (secondaryContextFilter) {
        null -> null
        is SecondaryContextFilter.GroupChatScopeContext -> secondaryContextFilter.groupScopeInfo
        is SecondaryContextFilter.MsgContentTagContext -> null
      }

    val isUserSupportChat: Boolean =
      when (groupScopeInfo) {
        null -> false
        is GroupChatScopeInfo.MemberSupport -> groupScopeInfo.groupMember_ == null
      }

    suspend fun addChat(chat: Chat) {
      chats.add(index = 0, chat)
      popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = 0)
    }

    private suspend fun reorderChat(chat: Chat, toIndex: Int) {
      val newChats = SnapshotStateList<Chat>()
      newChats.addAll(chats.value)
      newChats.remove(chat)
      newChats.add(index = toIndex, chat)
      chats.replaceAll(newChats)
      popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = toIndex)
    }

    fun updateChatInfo(rhId: Long?, cInfo: ChatInfo) {
      val i = getChatIndex(rhId, cInfo.id)
      if (i >= 0) {
        val currentCInfo = chats[i].chatInfo
        var newCInfo = cInfo
        if (currentCInfo is ChatInfo.Direct && newCInfo is ChatInfo.Direct) {
          val currentStats = currentCInfo.contact.activeConn?.connectionStats
          val newConn = newCInfo.contact.activeConn
          val newStats = newConn?.connectionStats
          if (currentStats != null && newConn != null && newStats == null) {
            newCInfo = newCInfo.copy(
              contact = newCInfo.contact.copy(
                activeConn = newConn.copy(
                  connectionStats = currentStats
                )
              )
            )
          }
        } else if (currentCInfo is ChatInfo.Group && newCInfo is ChatInfo.Group && newCInfo.groupChatScope != null) {
          newCInfo = newCInfo.copy(groupInfo = newCInfo.groupInfo, groupChatScope = null)
        }
        chats[i] = chats[i].copy(chatInfo = newCInfo)
      }
    }

    fun updateChatStats(rhId: Long?, chatId: ChatId, chatStats: Chat.ChatStats) {
      val i = getChatIndex(rhId, chatId)
      if (i >= 0) {
        chats[i] = chats[i].copy(chatStats = chatStats)
      }
    }

    suspend fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection))

    suspend fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed)

    suspend fun updateContactConnectionStats(rhId: Long?, contact: Contact, connectionStats: ConnectionStats) {
      val updatedConn = contact.activeConn?.copy(connectionStats = connectionStats)
      val updatedContact = contact.copy(activeConn = updatedConn)
      updateContact(rhId, updatedContact)
    }

    suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo, groupChatScope = null))

    private suspend fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) {
      if (hasChat(rhId, cInfo.id)) {
        updateChatInfo(rhId, cInfo)
      } else if (addMissing) {
        addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf()))
        addPresetChatTags(cInfo, Chat.ChatStats())
      }
    }

    fun updateChats(newChats: List<Chat>, keepingChatId: String? = null) {
      if (keepingChatId != null) {
        val chatToKeep = getChat(keepingChatId)
        val indexToRemove = newChats.indexOfFirst { it.id == keepingChatId }
        if (chatToKeep != null && indexToRemove != -1) {
          val remainingNewChats = newChats.toMutableList().apply { removeAt(indexToRemove) }
          chats.replaceAll(listOf(chatToKeep) + remainingNewChats)
        } else {
          chats.replaceAll(newChats)
        }
      } else {
        chats.replaceAll(newChats)
      }
      popChatCollector.clear()

      val cId = chatId.value
      // If chat is null, it was deleted in background after apiGetChats call
      if (cId != null && getChat(cId) == null) {
        chatId.value = null
      }
    }

    suspend fun replaceChat(rhId: Long?, id: String, chat: Chat) {
      val i = getChatIndex(rhId, id)
      if (i >= 0) {
        chats[i] = chat
      } else {
        // invalid state, correcting
        addChat(chat)
      }
    }

    fun addToChatItems(index: Int, elem: ChatItem) {
      chatItems.value = SnapshotStateList<ChatItem>().apply { addAll(chatItems.value); add(index, elem); chatState.itemAdded(elem.id to elem.isRcvNew) }
    }

    fun addToChatItems(elem: ChatItem) {
      chatItems.value = SnapshotStateList<ChatItem>().apply { addAll(chatItems.value); add(elem); chatState.itemAdded(elem.id to elem.isRcvNew) }
    }

    fun removeLastChatItems() {
      val remIndex: Int
      val rem: ChatItem?
      chatItems.value = SnapshotStateList<ChatItem>().apply {
        addAll(chatItems.value)
        remIndex = lastIndex
        rem = removeLastOrNull()
      }
      if (rem != null) {
        val removed = Triple(rem.id, remIndex, rem.isRcvNew)
        chatState.itemsRemoved(listOf(removed), chatItems.value)
      }
    }

    suspend fun addChatItem(rhId: Long?, chatInfo: ChatInfo, cItem: ChatItem) {
      // updates membersRequireAttention
      val cInfo = if (chatInfo is ChatInfo.Direct && chatInfo.chatDeleted) {
        // mark chat non deleted
        val updatedContact = chatInfo.contact.copy(chatDeleted = false)
        ChatInfo.Direct(updatedContact)
      } else {
        chatInfo
      }
      updateChatInfo(rhId, cInfo)
      // update chat list
      val i = getChatIndex(rhId, cInfo.id)
      val chat: Chat
      if (i >= 0) {
        chat = chatsContext.chats[i]
        // update preview (for chat from main scope to show new items for invitee in pending status)
        if (cInfo.groupChatScope() == null || cInfo.groupInfo_?.membership?.memberPending == true) {
          val newPreviewItem = when (cInfo) {
            is ChatInfo.Group -> {
              val currentPreviewItem = chat.chatItems.firstOrNull()
              if (currentPreviewItem != null) {
                if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) {
                  cItem
                } else {
                  currentPreviewItem
                }
              } else {
                cItem
              }
            }

            else -> cItem
          }
          val wasUnread = chat.unreadTag
          chatsContext.chats[i] = chat.copy(
            chatItems = arrayListOf(newPreviewItem),
            chatStats =
            if (cItem.meta.itemStatus is CIStatus.RcvNew) {
              increaseUnreadCounter(rhId, currentUser.value!!)
              chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions)
            } else
              chat.chatStats
          )
          updateChatTagReadInPrimaryContext(chatsContext.chats[i], wasUnread)
        }
        // pop chat
        if (appPlatform.isDesktop && cItem.chatDir.sent) {
          reorderChat(chatsContext.chats[i], 0)
        } else {
          popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i)
        }
      } else {
        if (cInfo.groupChatScope() == null) {
          addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
        } else {
          addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = emptyList()))
        }
      }
      // add to current scope
      withContext(Dispatchers.Main) {
        if (chatItemBelongsToScope(cInfo, cItem)) {
          // Prevent situation when chat item already in the list received from backend
          if (chatItems.value.none { it.id == cItem.id }) {
            if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
              addToChatItems(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
            } else {
              addToChatItems(cItem)
            }
          }
        }
      }
    }

    private fun chatItemBelongsToScope(cInfo: ChatInfo, cItem: ChatItem): Boolean =
      when (secondaryContextFilter) {
        null ->
          chatId.value == cInfo.id && cInfo.groupChatScope() == null
        is SecondaryContextFilter.GroupChatScopeContext -> {
          val cInfoScope = cInfo.groupChatScope()
          if (cInfoScope != null) {
            chatId.value == cInfo.id && sameChatScope(cInfoScope, secondaryContextFilter.groupScopeInfo.toChatScope())
          } else {
            false
          }
        }
        is SecondaryContextFilter.MsgContentTagContext ->
          chatId.value == cInfo.id && cItem.isReport
      }

    suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean {
      var itemAdded = false
      // update chat list
      if (cInfo.groupChatScope() == null) {
        val i = getChatIndex(rhId, cInfo.id)
        val chat: Chat
        if (i >= 0) {
          chat = chats[i]
          val pItem = chat.chatItems.lastOrNull()
          if (pItem?.id == cItem.id) {
            chats[i] = chat.copy(chatItems = arrayListOf(cItem))
            if (pItem.isRcvNew && !cItem.isRcvNew) {
              // status changed from New to Read, update counter
              decreaseCounterInPrimaryContext(rhId, cInfo.id)
            }
          }
        } else {
          addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
          itemAdded = true
        }
      }
      // update current scope
      withContext(Dispatchers.Main) {
        if (chatItemBelongsToScope(cInfo, cItem)) {
          if (cItem.isDeletedContent || cItem.meta.itemDeleted != null) {
            AudioPlayer.stop(cItem)
          }
          val items = chatItems.value
          val itemIndex = items.indexOfFirst { it.id == cItem.id }
          if (itemIndex >= 0) {
            val oldStatus = items[itemIndex].meta.itemStatus
            val newStatus = cItem.meta.itemStatus
            val ci = if (shouldKeepOldSndCIStatus(oldStatus, newStatus)) {
              cItem.copy(meta = cItem.meta.copy(itemStatus = oldStatus))
            } else {
              cItem
            }
            items[itemIndex] = ci
          } else {
            addToChatItems(cItem)
            itemAdded = true
          }
        }
      }
      return  itemAdded
    }

    suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null, atIndex: Int? = null) {
      withContext(Dispatchers.Main) {
        if (chatItemBelongsToScope(cInfo, cItem)) {
          val items = chatItems.value
          val itemIndex = atIndex ?: items.indexOfFirst { it.id == cItem.id }
          if (itemIndex >= 0) {
            items[itemIndex] = cItem
          }
        }
      }
    }

    fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) {
      // update chat list
      if (cInfo.groupChatScope() == null) {
        if (cItem.isRcvNew) {
          decreaseCounterInPrimaryContext(rhId, cInfo.id)
        }
        // update preview
        val i = getChatIndex(rhId, cInfo.id)
        val chat: Chat
        if (i >= 0) {
          chat = chats[i]
          val pItem = chat.chatItems.lastOrNull()
          if (pItem?.id == cItem.id) {
            chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy))
          }
        }
      }
      // remove from current scope
      if (chatItemBelongsToScope(cInfo, cItem)) {
        chatItems.removeAllAndNotify {
          // We delete taking into account meta.createdAt to make sure we will not be in situation when two items with the same id will be deleted
          // (it can happen if already deleted chat item in backend still in the list and new one came with the same (re-used) chat item id)
          val remove = it.id == cItem.id && it.meta.createdAt == cItem.meta.createdAt
          if (remove) { AudioPlayer.stop(it) }
          remove
        }
      }
    }

    suspend fun removeMemberItems(rhId: Long?, removedMember: GroupMember, byMember: GroupMember, groupInfo: GroupInfo) {
      fun removedUpdatedItem(item: ChatItem): ChatItem? {
        val newContent = when {
          item.chatDir is CIDirection.GroupSnd && removedMember.groupMemberId == groupInfo.membership.groupMemberId -> CIContent.SndModerated
          item.chatDir is CIDirection.GroupRcv && item.chatDir.groupMember.groupMemberId == removedMember.groupMemberId -> CIContent.RcvModerated
          else -> return null
        }
        val updatedItem = item.copy(
          meta = item.meta.copy(itemDeleted = CIDeleted.Moderated(Clock.System.now(), byGroupMember = byMember)),
          content = if (groupInfo.fullGroupPreferences.fullDelete.on) newContent else item.content
        )
        if (item.isActiveReport) {
          decreaseGroupReportsCounter(rhId, groupInfo.id)
        }
        return updatedItem
      }

      // this should not happen, only another member can "remove" user, user can only "leave" (another event).
      if (byMember.groupMemberId == groupInfo.membership.groupMemberId) {
        Log.d(TAG, "exiting removeMemberItems")
        return
      }
      val cInfo = ChatInfo.Group(groupInfo, groupChatScope = null) // TODO [knocking] review
      if (chatId.value == groupInfo.id) {
        for (i in 0 until chatItems.value.size) {
          val updatedItem = removedUpdatedItem(chatItems.value[i])
          if (updatedItem != null) {
            updateChatItem(cInfo, updatedItem, atIndex = i)
          }
        }
      } else {
        val i = getChatIndex(rhId, groupInfo.id)
        val chat = chats[i]
        if (chat.chatItems.isNotEmpty()) {
          val updatedItem = removedUpdatedItem(chat.chatItems[0])
          if (updatedItem != null) {
            chats.value[i] = chat.copy(chatItems = listOf(updatedItem))
          }
        }
      }
    }

    fun clearChat(rhId: Long?, cInfo: ChatInfo) {
      // clear preview
      val i = getChatIndex(rhId, cInfo.id)
      if (i >= 0) {
        decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount)
        val chatBefore = chats[i]
        chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo)
        markChatTagRead(chatBefore)
      }
      // clear current chat
      if (chatId.value == cInfo.id) {
        chatItems.clearAndNotify()
      }
    }

    val popChatCollector = PopChatCollector(this)

    // TODO [contexts] no reason for this to be nested?
    class PopChatCollector(chatsCtx: ChatsContext) {
      private val subject = MutableSharedFlow<Unit>()
      private var remoteHostId: Long? = null
      private val chatsToPop = mutableMapOf<ChatId, Instant>()

      init {
        withLongRunningApi {
          subject
            .throttleLatest(2000)
            .collect {
              withContext(Dispatchers.Main) {
                chatsCtx.chats.replaceAll(popCollectedChats())
              }
            }
        }
      }

      suspend fun throttlePopChat(rhId: Long?, chatId: ChatId, currentPosition: Int) {
        if (rhId != remoteHostId) {
          chatsToPop.clear()
          remoteHostId = rhId
        }
        if (currentPosition > 0 || chatsToPop.isNotEmpty()) {
          chatsToPop[chatId] = Clock.System.now()
          subject.emit(Unit)
        }
      }

      fun clear() = chatsToPop.clear()

      private fun popCollectedChats(): List<Chat> {
        val chs = mutableListOf<Chat>()
        // collect chats that received updates
        for ((chatId, popTs) in chatsToPop.entries) {
          val ch = getChat(chatId)
          if (ch != null) {
            ch.popTs = popTs
            chs.add(ch)
          }
        }
        // sort chats by pop timestamp in descending order
        val newChats = ArrayList(chs.sortedByDescending { it.popTs })
        newChats.addAll(chats.value.filter { !chatsToPop.containsKey(it.chatInfo.id) } )
        chatsToPop.clear()
        return newChats
      }
    }

    fun markChatItemsRead(remoteHostId: Long?, id: ChatId, itemIds: List<Long>? = null) {
      val (markedRead, mentionsMarkedRead) = markItemsReadInCurrentChat(id, itemIds)
      // update preview
      val chatIdx = getChatIndex(remoteHostId, id)
      if (chatIdx >= 0) {
        val chat = chats[chatIdx]
        val lastId = chat.chatItems.lastOrNull()?.id
        if (lastId != null) {
          val wasUnread = chat.unreadTag
          val unreadCount = if (itemIds != null) chat.chatStats.unreadCount - markedRead else 0
          val unreadMentions = if (itemIds != null) chat.chatStats.unreadMentions - mentionsMarkedRead else 0
          decreaseUnreadCounter(remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
          chats[chatIdx] = chat.copy(
            chatStats = chat.chatStats.copy(unreadCount = unreadCount, unreadMentions = unreadMentions)
          )
          updateChatTagReadInPrimaryContext(chats[chatIdx], wasUnread)
        }
      }
    }

    private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List<Long>? = null): Pair<Int, Int> {
      var markedRead = 0
      var mentionsMarkedRead = 0
      if (chatId.value == id) {
        val items = chatItems.value
        var i = items.lastIndex
        val itemIdsFromRange = itemIds?.toMutableSet() ?: mutableSetOf()
        val markedReadIds = mutableSetOf<Long>()
        while (i >= 0) {
          val item = items[i]
          if (item.meta.itemStatus is CIStatus.RcvNew && (itemIds == null || itemIdsFromRange.contains(item.id))) {
            val newItem = item.withStatus(CIStatus.RcvRead())
            items[i] = newItem
            if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
              items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
                deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
              )
            }
            markedReadIds.add(item.id)
            markedRead++
            if (item.meta.userMention) {
              mentionsMarkedRead++
            }
            if (itemIds != null) {
              itemIdsFromRange.remove(item.id)
              // already set all needed items as read, can finish the loop
              if (itemIdsFromRange.isEmpty()) break
            }
          }
          i--
        }
        chatState.itemsRead(if (itemIds != null) markedReadIds else null, items)
      }
      return markedRead to mentionsMarkedRead
    }

    private fun decreaseCounterInPrimaryContext(rhId: Long?, chatId: ChatId) {
      // updates anything only in main ChatView, not GroupReportsView or anything else from the future
      if (secondaryContextFilter != null) return

      val chatIndex = getChatIndex(rhId, chatId)
      if (chatIndex == -1) return

      val chat = chats[chatIndex]
      val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0)
      val wasUnread = chat.unreadTag
      decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
      chats[chatIndex] = chat.copy(
        chatStats = chat.chatStats.copy(
          unreadCount = unreadCount,
        )
      )
      updateChatTagReadInPrimaryContext(chats[chatIndex], wasUnread)
    }

    fun removeChat(rhId: Long?, id: String) {
      val i = getChatIndex(rhId, id)
      if (i != -1) {
        val chat = chats.removeAt(i)
        removePresetChatTags(chat.chatInfo, chat.chatStats)
        removeWallpaperFilesFromChat(chat)
      }
    }

    suspend fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean {
      // user member was updated
      if (groupInfo.membership.groupMemberId == member.groupMemberId) {
        updateGroup(rhId, groupInfo)
        return false
      }
      // update current chat
      return if (chatId.value == groupInfo.id) {
        if (groupMembers.value.isNotEmpty() && groupMembers.value.firstOrNull()?.groupId != groupInfo.groupId) {
          // stale data, should be cleared at that point, otherwise, duplicated items will be here which will produce crashes in LazyColumn
          groupMembers.value = emptyList()
          groupMembersIndexes.value = emptyMap()
        }
        val memberIndex = groupMembersIndexes.value[member.groupMemberId]
        val updated = chatItems.value.map {
          // Take into account only specific changes, not all. Other member updates are not important and can be skipped
          if (it.chatDir is CIDirection.GroupRcv && it.chatDir.groupMember.groupMemberId == member.groupMemberId &&
            (it.chatDir.groupMember.image != member.image ||
                it.chatDir.groupMember.chatViewName != member.chatViewName ||
                it.chatDir.groupMember.blocked != member.blocked ||
                it.chatDir.groupMember.memberRole != member.memberRole)
            )
            it.copy(chatDir = CIDirection.GroupRcv(member))
          else
            it
        }
        if (updated != chatItems.value) {
          chatItems.replaceAll(updated)
        }
        val gMembers = groupMembers.value.toMutableList()
        if (memberIndex != null) {
          gMembers[memberIndex] = member
          groupMembers.value = gMembers
          false
        } else {
          gMembers.add(member)
          groupMembers.value = gMembers
          val gmIndexes = groupMembersIndexes.value.toMutableMap()
          gmIndexes[member.groupMemberId] = groupMembers.size - 1
          groupMembersIndexes.value = gmIndexes
          true
        }
      } else {
        false
      }
    }

    suspend fun updateGroupMemberConnectionStats(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) {
      val memberConn = member.activeConn
      if (memberConn != null) {
        val updatedConn = memberConn.copy(connectionStats = connectionStats)
        val updatedMember = member.copy(activeConn = updatedConn)
        upsertGroupMember(rhId, groupInfo, updatedMember)
      }
    }

    fun increaseUnreadCounter(rhId: Long?, user: UserLike) {
      changeUnreadCounterInPrimaryContext(rhId, user, 1)
    }

    fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) {
      changeUnreadCounterInPrimaryContext(rhId, user, -by)
    }

    private fun changeUnreadCounterInPrimaryContext(rhId: Long?, user: UserLike, by: Int) {
      // updates anything only in main ChatView, not GroupReportsView or anything else from the future
      if (secondaryContextFilter != null) return

      val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId }
      if (i != -1) {
        users[i] = users[i].copy(unreadCount = users[i].unreadCount + by)
      }
    }

    fun updateChatTagReadInPrimaryContext(chat: Chat, wasUnread: Boolean) {
      // updates anything only in main ChatView, not GroupReportsView or anything else from the future
      if (secondaryContextFilter != null) return

      val tags = chat.chatInfo.chatTags ?: return
      val nowUnread = chat.unreadTag

      if (nowUnread && !wasUnread) {
        tags.forEach { tag ->
          unreadTags[tag] = (unreadTags[tag] ?: 0) + 1
        }
      } else if (!nowUnread && wasUnread) {
        markChatTagReadInPrimaryContext_(chat, tags)
      }
    }

    fun markChatTagRead(chat: Chat) {
      if (chat.unreadTag) {
        chat.chatInfo.chatTags?.let { tags ->
          markChatTagReadInPrimaryContext_(chat, tags)
        }
      }
    }

    private fun markChatTagReadInPrimaryContext_(chat: Chat, tags: List<Long>) {
      // updates anything only in main ChatView, not GroupReportsView or anything else from the future
      if (secondaryContextFilter != null) return

      for (tag in tags) {
        val count = unreadTags[tag]
        if (count != null) {
          unreadTags[tag] = maxOf(0, count - 1)
        }
      }
    }

    fun increaseGroupReportsCounter(rhId: Long?, chatId: ChatId) {
      changeGroupReportsCounter(rhId, chatId, 1)
    }

    fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 1) {
      changeGroupReportsCounter(rhId, chatId, -by)
    }

    private fun changeGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 0) {
      if (by == 0) return

      val i = getChatIndex(rhId, chatId)
      if (i >= 0) {
        val chat = chats.value[i]
        chats[i] = chat.copy(
          chatStats = chat.chatStats.copy(
            reportsCount = (chat.chatStats.reportsCount + by).coerceAtLeast(0),
          )
        )
        val wasReportsCount = chat.chatStats.reportsCount
        val nowReportsCount = chats[i].chatStats.reportsCount
        val by = if (wasReportsCount == 0 && nowReportsCount > 0) 1 else if (wasReportsCount > 0 && nowReportsCount == 0) -1 else 0
        changeGroupReportsTagInPrimaryContext(by)
      }
    }

    private fun changeGroupReportsTagInPrimaryContext(by: Int = 0) {
      if (by == 0 || secondaryContextFilter != null) return
      presetTags[PresetTagKind.GROUP_REPORTS] = kotlin.math.max(0, (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by)
      clearActiveChatFilterIfNeeded()
    }
  }

  fun clearActiveChatFilterIfNeeded() {
    val clear = when(val f = activeChatTagFilter.value) {
      is ActiveFilter.PresetTag -> (presetTags[f.tag] ?: 0) == 0
      is ActiveFilter.UserTag -> userTags.value.none { it.chatTagId == f.tag.chatTagId }
      is ActiveFilter.Unread, null -> false
    }
    if (clear) activeChatTagFilter.value = null
  }

  fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) {
    val current = currentUser.value ?: return
    val updated = current.copy(
      profile = newProfile.toLocalProfile(current.profile.profileId),
      fullPreferences = preferences ?: current.fullPreferences
    )
    val i = users.indexOfFirst { it.user.userId == current.userId && it.user.remoteHostId == rhId }
    if (i != -1) {
      users[i] = users[i].copy(user = updated)
    }
    currentUser.value = updated
  }

  fun updateCurrentUserUiThemes(rhId: Long?, uiThemes: ThemeModeOverrides?) {
    val current = currentUser.value ?: return
    val updated = current.copy(
      uiThemes = uiThemes
    )
    val i = users.indexOfFirst { it.user.userId == current.userId && it.user.remoteHostId == rhId }
    if (i != -1) {
      users[i] = users[i].copy(user = updated)
    }
    currentUser.value = updated
  }

  suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem {
    val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
    withContext(Dispatchers.Main) {
      chatsContext.addToChatItems(cItem)
    }
    return cItem
  }

  fun removeLiveDummy() {
    if (chatsContext.chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
      withApi {
        withContext(Dispatchers.Main) {
          chatsContext.removeLastChatItems()
        }
      }
    }
  }

  fun getChatItemIndexOrNull(cItem: ChatItem, reversedChatItems: List<ChatItem>): Int? {
    val index = reversedChatItems.indexOfFirst { it.id == cItem.id }
    return if (index != -1) index else null
  }

  // this function analyses "connected" events and assumes that each member will be there only once
  fun getConnectedMemberNames(cItem: ChatItem, reversedChatItems: List<ChatItem>): Pair<Int, List<String>> {
    var count = 0
    val ns = mutableListOf<String>()
    var idx = getChatItemIndexOrNull(cItem, reversedChatItems)
    if (cItem.mergeCategory != null && idx != null) {
      while (idx < reversedChatItems.size) {
        val ci = reversedChatItems[idx]
        if (ci.mergeCategory != cItem.mergeCategory) break
        val m = ci.memberConnected
        if (m != null) {
          ns.add(m.displayName)
        }
        count++
        idx++
      }
    }
    return count to ns
  }

  // returns the index of the first item in the same merged group (the first hidden item)
  // and the previous visible item with another merge category
  fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?, reversedChatItems: List<ChatItem>): Pair<Int?, ChatItem?> {
    var i = ciIndex ?: return null to null
    val fst = reversedChatItems.lastIndex
    while (i < fst) {
      i++
      val ci = reversedChatItems[i]
      if (ciCategory == null || ciCategory != ci.mergeCategory) {
        return i - 1 to ci
      }
    }
    return i to null
  }

  // returns the previous member in the same merge group and the count of members in this group
  fun getPrevHiddenMember(member: GroupMember, range: IntRange, reversedChatItems: List<ChatItem>): Pair<GroupMember?, Int> {
    var prevMember: GroupMember? = null
    val names: MutableSet<Long> = mutableSetOf()
    for (i in range) {
      val dir = reversedChatItems[i].chatDir
      if (dir is CIDirection.GroupRcv) {
        val m = dir.groupMember
        if (prevMember == null && m.groupMemberId != member.groupMemberId) {
          prevMember = m
        }
        names.add(m.groupMemberId)
      }
    }
    return prevMember to names.size
  }

//  func popChat(_ id: String) {
//    if let i = getChatIndex(id) {
//      popChat_(i)
//    }
//  }

  fun replaceConnReqView(id: String, withId: String) {
    if (id == showingInvitation.value?.connId) {
      withApi {
        withContext(Dispatchers.Main) {
          showingInvitation.value = null
          chatsContext.chatItems.clearAndNotify()
          chatModel.chatId.value = withId
        }
      }
      ModalManager.start.closeModals()
      ModalManager.end.closeModals()
    }
  }

  fun dismissConnReqView(id: String) = withApi {
    if (id == showingInvitation.value?.connId) {
      withContext(Dispatchers.Main) {
        showingInvitation.value = null
        chatsContext.chatItems.clearAndNotify()
        chatModel.chatId.value = null
      }
      // Close NewChatView
      ModalManager.start.closeModals()
      ModalManager.center.closeModals()
      ModalManager.end.closeModals()
    }
  }

  fun markShowingInvitationUsed() {
    showingInvitation.value = showingInvitation.value?.copy(connChatUsed = true)
  }

  fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) {
    val conn = contact.activeConn
    if (conn != null) {
      networkStatuses[conn.agentConnId] = status
    }
  }

  fun contactNetworkStatus(contact: Contact): NetworkStatus {
    val conn = contact.activeConn
    return if (conn != null)
      networkStatuses[conn.agentConnId] ?: NetworkStatus.Unknown()
    else
      NetworkStatus.Unknown()
  }

  fun addTerminalItem(item: TerminalItem) {
    val maxItems = if (appPreferences.developerTools.get()) 500 else 200
    if (terminalsVisible.isNotEmpty()) {
      withApi {
        addTerminalItem(item, maxItems)
      }
    } else {
      addTerminalItem(item, maxItems)
    }
  }

  private fun addTerminalItem(item: TerminalItem, maxItems: Int) {
    if (terminalItems.value.size >= maxItems) {
      terminalItems.value = terminalItems.value.subList(1, terminalItems.value.size)
    }
    terminalItems.value += item
  }

  val connectedToRemote: Boolean @Composable get() = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
  fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
}

data class ShowingInvitation(
  val connId: String,
  val connLink: CreatedConnLink,
  val connChatUsed: Boolean,
  val conn: PendingContactConnection
)

enum class ChatType(val type: String) {
  Direct("@"),
  Group("#"),
  Local("*"),
  ContactRequest("<@"),
  ContactConnection(":");
}

sealed class GroupChatScope {
  class MemberSupport(val groupMemberId_: Long?): GroupChatScope()
}

fun sameChatScope(scope1: GroupChatScope, scope2: GroupChatScope) =
  scope1 is GroupChatScope.MemberSupport
      && scope2 is GroupChatScope.MemberSupport
      && scope1.groupMemberId_ == scope2.groupMemberId_

@Serializable
sealed class GroupChatScopeInfo {
  @Serializable @SerialName("memberSupport") data class MemberSupport(val groupMember_: GroupMember?) : GroupChatScopeInfo()

  fun toChatScope(): GroupChatScope =
    when (this) {
      is MemberSupport -> when (groupMember_) {
        null -> GroupChatScope.MemberSupport(groupMemberId_ = null)
        else -> GroupChatScope.MemberSupport(groupMemberId_ = groupMember_.groupMemberId)
      }
    }
}

@Serializable
data class User(
  val remoteHostId: Long?,
  override val userId: Long,
  val userContactId: Long,
  val localDisplayName: String,
  val profile: LocalProfile,
  val fullPreferences: FullChatPreferences,
  override val activeUser: Boolean,
  val activeOrder: Long,
  override val showNtfs: Boolean,
  val sendRcptsContacts: Boolean,
  val sendRcptsSmallGroups: Boolean,
  val autoAcceptMemberContacts: Boolean,
  val viewPwdHash: UserPwdHash?,
  val uiThemes: ThemeModeOverrides? = null,
): NamedChat, UserLike {
  override val displayName: String get() = profile.displayName
  override val fullName: String get() = profile.fullName
  override val shortDescr: String? get() = profile.shortDescr
  override val image: String? get() = profile.image
  override val localAlias: String = ""

  val hidden: Boolean = viewPwdHash != null

  val addressShared: Boolean = profile.contactLink != null

  fun updateRemoteHostId(rh: Long?): User =
    if (rh == null) this else this.copy(remoteHostId = rh)

  companion object {
    val sampleData = User(
      remoteHostId = null,
      userId = 1,
      userContactId = 1,
      localDisplayName = "alice",
      profile = LocalProfile.sampleData,
      fullPreferences = FullChatPreferences.sampleData,
      activeUser = true,
      activeOrder = 0,
      showNtfs = true,
      sendRcptsContacts = true,
      sendRcptsSmallGroups = false,
      autoAcceptMemberContacts = false,
      viewPwdHash = null,
      uiThemes = null,
    )
  }
}

@Serializable
data class UserRef(
  override val userId: Long,
  val localDisplayName: String,
  override val activeUser: Boolean,
  override val showNtfs: Boolean
): UserLike {}

interface UserLike {
  val userId: Long
  val activeUser: Boolean
  val showNtfs: Boolean

  val showNotifications: Boolean get() = activeUser || showNtfs
}

@Serializable
data class UserPwdHash(
  val hash: String,
  val salt: String
)

@Serializable
data class UserInfo(
  val user: User,
  val unreadCount: Int
) {
  companion object {
    val sampleData = UserInfo(
      user = User.sampleData,
      unreadCount = 1
    )
  }
}

typealias ChatId = String

interface NamedChat {
  val displayName: String
  val fullName: String
  val shortDescr: String?
  val image: String?
  val localAlias: String
  val chatViewName: String
    get() = localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }

  fun anyNameContains(searchAnyCase: String): Boolean {
    val s = searchAnyCase.trim().lowercase()
    return chatViewName.lowercase().contains(s) || displayName.lowercase().contains(s) || fullName.lowercase().contains(s)
  }
}

interface SomeChat {
  val chatType: ChatType
  val localDisplayName: String
  val id: ChatId
  val apiId: Long
  val ready: Boolean
  val chatDeleted: Boolean
  val nextConnect: Boolean
  val nextConnectPrepared: Boolean
  val profileChangeProhibited: Boolean
  val incognito: Boolean
  fun featureEnabled(feature: ChatFeature): Boolean
  val timedMessagesTTL: Int?
  val createdAt: Instant
  val updatedAt: Instant
}

@Serializable @Stable
data class Chat(
  val remoteHostId: Long?,
  val chatInfo: ChatInfo,
  val chatItems: List<ChatItem>,
  val chatStats: ChatStats = ChatStats()
) {
  @Transient
  var popTs: Instant? = null

  val nextSendGrpInv: Boolean
    get() = when (chatInfo) {
      is ChatInfo.Direct -> chatInfo.contact.nextSendGrpInv
      else -> false
    }

  val unreadTag: Boolean get() = when (chatInfo.chatSettings?.enableNtfs) {
    All -> chatStats.unreadChat || chatStats.unreadCount > 0
    Mentions -> chatStats.unreadChat || chatStats.unreadMentions > 0
    else -> chatStats.unreadChat
  }

  val id: String get() = chatInfo.id

  val supportUnreadCount: Int get() = when (chatInfo) {
    is ChatInfo.Group ->
      if (chatInfo.groupInfo.canModerate) {
        chatInfo.groupInfo.membersRequireAttention
      } else {
        chatInfo.groupInfo.membership.supportChat?.unread ?: 0
      }
    else -> 0
  }

  fun groupFeatureEnabled(feature: GroupFeature): Boolean =
    if (chatInfo is ChatInfo.Group) {
      chatInfo.groupInfo.groupFeatureEnabled(feature)
    } else {
      true
    }

  @Serializable
  data class ChatStats(
    val unreadCount: Int = 0,
    val unreadMentions: Int = 0,
    // actual only via getChats() and getChat(.initial), otherwise, zero
    val reportsCount: Int = 0,
    val minUnreadItemId: Long = 0,
    // actual only via getChats(), otherwise, false
    val unreadChat: Boolean = false
  )

  companion object {
    val sampleData = Chat(
      remoteHostId = null,
      chatInfo = ChatInfo.Direct.sampleData,
      chatItems = arrayListOf(ChatItem.getSampleData())
    )
  }
}

@Serializable
sealed class ChatInfo: SomeChat, NamedChat {

  @Serializable @SerialName("direct")
  data class Direct(val contact: Contact): ChatInfo() {
    override val chatType get() = ChatType.Direct
    override val localDisplayName get() = contact.localDisplayName
    override val id get() = contact.id
    override val apiId get() = contact.apiId
    override val ready get() = contact.ready
    override val chatDeleted get() = contact.chatDeleted
    override val nextConnect get() = contact.nextConnect
    override val nextConnectPrepared get() = contact.nextConnectPrepared
    override val profileChangeProhibited get() = contact.profileChangeProhibited
    override val incognito get() = contact.incognito
    override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature)
    override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL
    override val createdAt get() = contact.createdAt
    override val updatedAt get() = contact.updatedAt
    override val displayName get() = contact.displayName
    override val fullName get() = contact.fullName
    override val shortDescr get() = contact.profile.shortDescr
    override val image get() = contact.image
    override val localAlias: String get() = contact.localAlias
    override fun anyNameContains(searchAnyCase: String): Boolean = contact.anyNameContains(searchAnyCase)

    companion object {
      val sampleData = Direct(Contact.sampleData)
    }
  }

  @Serializable @SerialName("group")
  data class Group(val groupInfo: GroupInfo, val groupChatScope: GroupChatScopeInfo?): ChatInfo() {
    override val chatType get() = ChatType.Group
    override val localDisplayName get() = groupInfo.localDisplayName
    override val id get() = groupInfo.id
    override val apiId get() = groupInfo.apiId
    override val ready get() = groupInfo.ready
    override val chatDeleted get() = groupInfo.chatDeleted
    override val nextConnect get() = groupInfo.nextConnect
    override val nextConnectPrepared get() = groupInfo.nextConnectPrepared
    override val profileChangeProhibited get() = groupInfo.profileChangeProhibited
    override val incognito get() = groupInfo.incognito
    override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature)
    override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL
    override val createdAt get() = groupInfo.createdAt
    override val updatedAt get() = groupInfo.updatedAt
    override val displayName get() = groupInfo.displayName
    override val fullName get() = groupInfo.fullName
    override val shortDescr get() = groupInfo.groupProfile.shortDescr
    override val image get() = groupInfo.image
    override val localAlias get() = groupInfo.localAlias

    companion object {
      val sampleData = Group(GroupInfo.sampleData, groupChatScope = null)
    }
  }

  @Serializable @SerialName("local")
  data class Local(val noteFolder: NoteFolder): ChatInfo() {
    override val chatType get() = ChatType.Local
    override val localDisplayName get() = noteFolder.localDisplayName
    override val id get() = noteFolder.id
    override val apiId get() = noteFolder.apiId
    override val ready get() = noteFolder.ready
    override val chatDeleted get() = noteFolder.chatDeleted
    override val nextConnect get() = noteFolder.nextConnect
    override val nextConnectPrepared get() = noteFolder.nextConnectPrepared
    override val profileChangeProhibited get() = noteFolder.profileChangeProhibited
    override val incognito get() = noteFolder.incognito
    override fun featureEnabled(feature: ChatFeature) = noteFolder.featureEnabled(feature)
    override val timedMessagesTTL: Int? get() = noteFolder.timedMessagesTTL
    override val createdAt get() = noteFolder.createdAt
    override val updatedAt get() = noteFolder.updatedAt
    override val displayName get() = noteFolder.displayName
    override val fullName get() = noteFolder.fullName
    override val shortDescr get() = null
    override val image get() = noteFolder.image
    override val localAlias get() = noteFolder.localAlias

    companion object {
      val sampleData = Local(NoteFolder.sampleData)
    }
  }

  @Serializable @SerialName("contactRequest")
  class ContactRequest(val contactRequest: UserContactRequest): ChatInfo() {
    override val chatType get() = ChatType.ContactRequest
    override val localDisplayName get() = contactRequest.localDisplayName
    override val id get() = contactRequest.id
    override val apiId get() = contactRequest.apiId
    override val ready get() = contactRequest.ready
    override val chatDeleted get() = contactRequest.chatDeleted
    override val nextConnect get() = contactRequest.nextConnect
    override val nextConnectPrepared get() = contactRequest.nextConnectPrepared
    override val profileChangeProhibited get() = contactRequest.profileChangeProhibited
    override val incognito get() = contactRequest.incognito
    override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature)
    override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL
    override val createdAt get() = contactRequest.createdAt
    override val updatedAt get() = contactRequest.updatedAt
    override val displayName get() = contactRequest.displayName
    override val fullName get() = contactRequest.fullName
    override val shortDescr get() = contactRequest.profile.shortDescr
    override val image get() = contactRequest.image
    override val localAlias get() = contactRequest.localAlias

    companion object {
      val sampleData = ContactRequest(UserContactRequest.sampleData)
    }
  }

  @Serializable @SerialName("contactConnection")
  class ContactConnection(val contactConnection: PendingContactConnection): ChatInfo() {
    override val chatType get() = ChatType.ContactConnection
    override val localDisplayName get() = contactConnection.localDisplayName
    override val id get() = contactConnection.id
    override val apiId get() = contactConnection.apiId
    override val ready get() = contactConnection.ready
    override val chatDeleted get() = contactConnection.chatDeleted
    override val nextConnect get() = contactConnection.nextConnect
    override val nextConnectPrepared get() = contactConnection.nextConnectPrepared
    override val profileChangeProhibited get() = contactConnection.profileChangeProhibited
    override val incognito get() = contactConnection.incognito
    override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature)
    override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL
    override val createdAt get() = contactConnection.createdAt
    override val updatedAt get() = contactConnection.updatedAt
    override val displayName get() = contactConnection.displayName
    override val fullName get() = contactConnection.fullName
    override val shortDescr get() = null
    override val image get() = contactConnection.image
    override val localAlias get() = contactConnection.localAlias

    companion object {
      fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): ContactConnection =
        ContactConnection(PendingContactConnection.getSampleData(status, viaContactUri))
    }
  }

  @Serializable @SerialName("invalidJSON")
  class InvalidJSON(
    val json: String,
    override val apiId: Long = -idGenerator.getAndIncrement(),
    override val createdAt: Instant = Clock.System.now(),
    override val updatedAt: Instant = Clock.System.now()
  ): ChatInfo() {
    override val chatType get() = ChatType.Direct
    override val localDisplayName get() = invalidChatName
    override val id get() = "?$apiId"
    override val ready get() = false
    override val chatDeleted get() = false
    override val nextConnect get() = false
    override val nextConnectPrepared get() = false
    override val profileChangeProhibited get() = false
    override val incognito get() = false
    override fun featureEnabled(feature: ChatFeature) = false
    override val timedMessagesTTL: Int? get() = null
    override val displayName get() = invalidChatName
    override val fullName get() = invalidChatName
    override val shortDescr get() = null
    override val image get() = null
    override val localAlias get() = ""

    companion object {
      private val invalidChatName = generalGetString(MR.strings.invalid_chat)
      private val idGenerator = AtomicLong(0)
    }
  }

  val userCantSendReason: Pair<String, String?>?
    get() {
      when (this) {
        is Direct -> {
          if (contact.sendMsgToConnect) return null
          if (contact.nextAcceptContactRequest) { return generalGetString(MR.strings.cant_send_message_generic) to null }
          if (!contact.active) return generalGetString(MR.strings.cant_send_message_contact_deleted) to null
          if (!contact.sndReady) {
            return if (contact.preparedContact?.uiConnLinkType == ConnectionMode.Con) {
              generalGetString(MR.strings.cant_send_message_request_is_sent) to null
            } else {
              generalGetString(MR.strings.cant_send_message_contact_not_ready) to null
            }
          }
          if (contact.activeConn?.connectionStats?.ratchetSyncSendProhibited == true) return generalGetString(MR.strings.cant_send_message_contact_not_synchronized) to null
          if (contact.activeConn?.connDisabled == true) return generalGetString(MR.strings.cant_send_message_contact_disabled) to null
          return null
        }
        is Group -> {
          if (groupInfo.membership.memberActive) {
            when (groupChatScope) {
              null -> {
                if (groupInfo.membership.memberPending) {
                  return generalGetString(MR.strings.reviewed_by_admins) to generalGetString(MR.strings.observer_cant_send_message_desc)
                }
                if (groupInfo.membership.memberRole == GroupMemberRole.Observer) {
                  return generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc)
                }
                return null
              }
              is GroupChatScopeInfo.MemberSupport ->
                if (groupChatScope.groupMember_ != null) {
                  if (
                    groupChatScope.groupMember_.versionRange.maxVersion < GROUP_KNOCKING_VERSION
                    && !groupChatScope.groupMember_.memberPending
                  ) {
                    return generalGetString(MR.strings.cant_send_message_member_has_old_version) to null
                  }
                  return null
                } else {
                  return null
                }
            }
          } else if (groupInfo.nextConnectPrepared) {
            return null
          } else {
            return when (groupInfo.membership.memberStatus) {
              GroupMemberStatus.MemRejected -> generalGetString(MR.strings.cant_send_message_rejected) to null
              GroupMemberStatus.MemGroupDeleted -> generalGetString(MR.strings.cant_send_message_group_deleted) to null
              GroupMemberStatus.MemRemoved -> generalGetString(MR.strings.cant_send_message_mem_removed) to null
              GroupMemberStatus.MemLeft -> generalGetString(MR.strings.cant_send_message_you_left) to null
              else -> generalGetString(MR.strings.cant_send_message_generic) to null
            }
          }
        }
        is Local ->
          return null
        is ContactRequest ->
          return generalGetString(MR.strings.cant_send_message_generic) to null
        is ContactConnection ->
          return generalGetString(MR.strings.cant_send_message_generic) to null
        is InvalidJSON ->
          return generalGetString(MR.strings.cant_send_message_generic) to null
      }
    }

  val sendMsgEnabled get() = userCantSendReason == null

  val sndReady: Boolean get() =
      when(this) {
        is Direct -> contact.sndReady
        is Group ->
          groupInfo.membership.memberActive
              && (groupChatScope != null || (!groupInfo.membership.memberPending && groupInfo.membership.memberRole != GroupMemberRole.Observer))
        is Local -> true
        is ContactRequest -> false
        is ContactConnection -> false
        is InvalidJSON -> false
      }

  fun groupChatScope(): GroupChatScope? = when (this) {
    is Group -> groupChatScope?.toChatScope()
    else -> null
  }

  fun ntfsEnabled(ci: ChatItem): Boolean =
    ntfsEnabled(ci.meta.userMention)

  fun ntfsEnabled(userMention: Boolean): Boolean =
    when (chatSettings?.enableNtfs) {
      All -> true
      Mentions -> userMention
      else -> false
    }

  val chatSettings
    get() = when(this) {
      is Direct -> contact.chatSettings
      is Group -> groupInfo.chatSettings
      else -> null
    }

  val chatTs: Instant
    get() = when(this) {
      is Direct -> contact.chatTs ?: contact.updatedAt
      is Group -> groupInfo.chatTs ?: groupInfo.updatedAt
      is Local -> noteFolder.chatTs
      is ContactRequest -> contactRequest.updatedAt
      is ContactConnection -> contactConnection.updatedAt
      is InvalidJSON -> updatedAt
    }

  val chatTags: List<Long>?
    get() = when (this) {
      is Direct -> contact.chatTags
      is Group -> groupInfo.chatTags
      else -> null
    }

  val nextNtfMode: MsgFilter? get() = this.chatSettings?.enableNtfs?.nextMode(mentions = this.hasMentions)

  val hasMentions: Boolean get() = this is Group

  val useCommands: Boolean get() = when(this) {
    is Direct -> contact.isBot
    is Group -> groupInfo.groupProfile.groupPreferences?.commands?.isNotEmpty() ?: false
    else -> false
  }

  val menuCommands: List<ChatBotCommand> get() = when(this) {
    is Direct ->
      if (contact.isBot) contact.profile.preferences?.commands ?: emptyList()
      else emptyList()
    is Group -> groupInfo.groupProfile.groupPreferences?.commands ?: emptyList()
    else -> emptyList()
  }

  val contactCard: Boolean
    get() = when (this) {
      is Direct -> contact.isContactCard
      else -> false
    }

  val groupInfo_: GroupInfo?
    get() = when (this) {
      is Group -> groupInfo
      else -> null
    }
}

@Serializable
sealed class NetworkStatus {
  val statusString: String get() =
    when (this) {
      is Connected -> generalGetString(MR.strings.server_connected)
      is Error -> generalGetString(MR.strings.server_error)
      else -> generalGetString(MR.strings.server_connecting)
    }
  val statusExplanation: String get() =
    when (this) {
      is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact)
      is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), connectionError)
      else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages)
    }

  @Serializable @SerialName("unknown") class Unknown: NetworkStatus()
  @Serializable @SerialName("connected") class Connected: NetworkStatus()
  @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
  @Serializable @SerialName("error") class Error(val connectionError: String): NetworkStatus()
}

@Serializable
data class ConnNetworkStatus(val agentConnId: String, val networkStatus: NetworkStatus)

@Serializable
data class Contact(
  val contactId: Long,
  override val localDisplayName: String,
  val profile: LocalProfile,
  val activeConn: Connection? = null,
  val viaGroup: Long? = null,
  val contactUsed: Boolean,
  val contactStatus: ContactStatus,
  val chatSettings: ChatSettings,
  val userPreferences: ChatPreferences,
  val mergedPreferences: ContactUserPreferences,
  override val createdAt: Instant,
  override val updatedAt: Instant,
  val chatTs: Instant?,
  val preparedContact: PreparedContact?,
  val contactRequestId: Long?,
  val contactGroupMemberId: Long? = null,
  val contactGrpInvSent: Boolean,
  val groupDirectInv: GroupDirectInvitation? = null,
  val chatTags: List<Long>,
  val chatItemTTL: Long?,
  override val chatDeleted: Boolean,
  val uiThemes: ThemeModeOverrides? = null,
): SomeChat, NamedChat {
  override val chatType get() = ChatType.Direct
  override val id get() = "@$contactId"
  override val apiId get() = contactId
  override val ready get() = activeConn?.connStatus == ConnStatus.Ready
  val sndReady get() = ready || activeConn?.connStatus == ConnStatus.SndReady
  val active get() = contactStatus == ContactStatus.Active
  override val nextConnect get() = sendMsgToConnect
  val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent
  override val nextConnectPrepared get() = active && preparedContact != null && (activeConn == null || activeConn.connStatus == ConnStatus.Prepared)
  override val profileChangeProhibited get() = activeConn != null
  val nextAcceptContactRequest get() =
    active &&
        (contactRequestId != null || groupDirectInv != null) &&
        (activeConn == null || activeConn.connStatus == ConnStatus.New || activeConn.connStatus == ConnStatus.Prepared)
  val sendMsgToConnect get() = nextSendGrpInv || nextConnectPrepared
  override val incognito get() = contactConnIncognito
  override fun featureEnabled(feature: ChatFeature) = when (feature) {
    ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
    ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
    ChatFeature.Reactions -> mergedPreferences.reactions.enabled.forUser
    ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
    ChatFeature.Files -> mergedPreferences.files.enabled.forUser
    ChatFeature.Calls -> mergedPreferences.calls.enabled.forUser
  }
  override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null }
  override val displayName get() = localAlias.ifEmpty { profile.displayName }
  override val fullName get() = profile.fullName
  override val shortDescr get() = profile.shortDescr
  override val image get() = profile.image
  val contactLink: String? = profile.contactLink
  override val localAlias get() = profile.localAlias
  val verified get() = activeConn?.connectionCode != null

  override fun anyNameContains(searchAnyCase: String): Boolean {
    val s = searchAnyCase.trim().lowercase()
    return profile.chatViewName.lowercase().contains(s) || profile.displayName.lowercase().contains(s) || profile.fullName.lowercase().contains(s)
  }

  val directOrUsed: Boolean get() =
    if (activeConn != null) {
      (activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
    } else {
      true
    }

  val isContactCard: Boolean get() =
    (activeConn == null || activeConn.connStatus == ConnStatus.Prepared) && profile.contactLink != null && active && preparedContact == null && contactRequestId == null

  val isBot: Boolean get() = profile.peerType == ChatPeerType.Bot

  val contactConnIncognito: Boolean get() =
    activeConn?.customUserProfileId != null

  val chatIconName: ImageResource get() =
    if (isBot) MR.images.ic_cube else MR.images.ic_account_circle_filled

  fun allowsFeature(feature: ChatFeature): Boolean = when (feature) {
    ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
    ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
    ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
    ChatFeature.Files -> mergedPreferences.files.contactPreference.allow != FeatureAllowed.NO
    ChatFeature.Reactions -> mergedPreferences.reactions.contactPreference.allow != FeatureAllowed.NO
    ChatFeature.Calls -> mergedPreferences.calls.contactPreference.allow != FeatureAllowed.NO
  }

  fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) {
    ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO
    ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
    ChatFeature.Reactions -> mergedPreferences.reactions.userPreference.pref.allow != FeatureAllowed.NO
    ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
    ChatFeature.Files -> mergedPreferences.files.userPreference.pref.allow != FeatureAllowed.NO
    ChatFeature.Calls -> mergedPreferences.calls.userPreference.pref.allow != FeatureAllowed.NO
  }

  companion object {
    val sampleData = Contact(
      contactId = 1,
      localDisplayName = "alice",
      profile = LocalProfile.sampleData,
      activeConn = Connection.sampleData,
      contactUsed = true,
      contactStatus = ContactStatus.Active,
      chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false),
      userPreferences = ChatPreferences.sampleData,
      mergedPreferences = ContactUserPreferences.sampleData,
      createdAt = Clock.System.now(),
      updatedAt = Clock.System.now(),
      chatTs = Clock.System.now(),
      preparedContact = null,
      contactRequestId = null,
      contactGrpInvSent = false,
      chatDeleted = false,
      uiThemes = null,
      chatTags = emptyList(),
      chatItemTTL = null,
    )
  }
}

@Serializable
data class NavigationInfo(
  val afterUnread: Int = 0,
  val afterTotal: Int = 0
)

@Serializable
data class PreparedContact (
  val connLinkToConnect: CreatedConnLink,
  val uiConnLinkType: ConnectionMode
)

@Serializable
data class GroupDirectInvitation (
  val groupDirectInvLink: String,
  val fromGroupId_: Long?,
  val fromGroupMemberId_: Long?,
  val fromGroupMemberConnId_: Long?,
  val groupDirectInvStartedConnection: Boolean
) {
  val memberRemoved: Boolean
    get() = fromGroupId_ == null || fromGroupMemberId_ == null || fromGroupMemberConnId_ == null
}

@Serializable
enum class ConnectionMode {
  @SerialName("inv") Inv,
  @SerialName("con") Con
}

@Serializable
enum class ContactStatus {
  @SerialName("active") Active,
  @SerialName("deleted") Deleted,
  @SerialName("deletedByUser") DeletedByUser;
}

@Serializable
class ContactRef(
  val contactId: Long,
  val agentConnId: String,
  val connId: Long,
  var localDisplayName: String
) {
  val id: ChatId get() = "@$contactId"
}

@Serializable
class ContactSubStatus(
  val contact: Contact,
  val contactError: ChatError? = null
)

@Serializable
data class Connection(
  val connId: Long,
  val agentConnId: String,
  val peerChatVRange: VersionRange,
  val connStatus: ConnStatus,
  val connLevel: Int,
  val viaGroupLink: Boolean,
  val customUserProfileId: Long? = null,
  val connectionCode: SecurityCode? = null,
  val pqSupport: Boolean,
  val pqEncryption: Boolean,
  val pqSndEnabled: Boolean? = null,
  val pqRcvEnabled: Boolean? = null,
  val connectionStats: ConnectionStats? = null,
  val authErrCounter: Int,
  val quotaErrCounter: Int
) {
  val id: ChatId get() = ":$connId"

  val connDisabled: Boolean
    get() = authErrCounter >= 10 // authErrDisableCount in core

  val connInactive: Boolean
    get() = quotaErrCounter >= 5 // quotaErrInactiveCount in core

  val connPQEnabled: Boolean
    get() = pqSndEnabled == true && pqRcvEnabled == true

  companion object {
    val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, peerChatVRange = VersionRange(1, 1), customUserProfileId = null, pqSupport = false, pqEncryption = false, authErrCounter = 0, quotaErrCounter = 0)
  }
}

@Serializable
data class VersionRange(val minVersion: Int, val maxVersion: Int)

@Serializable
data class SecurityCode(val securityCode: String, val verifiedAt: Instant)

@Serializable
data class Profile(
  override val displayName: String,
  override val fullName: String,
  override val shortDescr: String?,
  override val image: String? = null,
  override val localAlias : String = "",
  val contactLink: String? = null,
  val preferences: ChatPreferences? = null,
  val peerType: ChatPeerType? = null
): NamedChat {
  val profileViewName: String
    get() {
      return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
    }

  fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType)

  companion object {
    val sampleData = Profile(
      displayName = "alice",
      fullName = "Alice",
      shortDescr = null,
    )
  }
}

@Serializable
data class LocalProfile(
  val profileId: Long,
  override val displayName: String,
  override val fullName: String,
  override val shortDescr: String?,
  override val image: String? = null,
  override val localAlias: String,
  val contactLink: String? = null,
  val preferences: ChatPreferences? = null,
  val peerType: ChatPeerType? = null
): NamedChat {
  val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }

  fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType)

  companion object {
    val sampleData = LocalProfile(
      profileId = 1L,
      displayName = "alice",
      fullName = "Alice",
      shortDescr = null,
      preferences = ChatPreferences.sampleData,
      localAlias = ""
    )
  }
}

@Serializable
enum class ChatPeerType {
  @SerialName("human") Human,
  @SerialName("bot") Bot
}

@Serializable
data class UserProfileUpdateSummary(
  val updateSuccesses: Int,
  val updateFailures: Int,
  val changedContacts: List<Contact>
)

@Serializable
class Group (
  val groupInfo: GroupInfo,
  var members: List<GroupMember>
)

@Serializable
sealed class ForwardConfirmation {
  @Serializable @SerialName("filesNotAccepted") data class FilesNotAccepted(val fileIds: List<Long>) : ForwardConfirmation()
  @Serializable @SerialName("filesInProgress") data class FilesInProgress(val filesCount: Int) : ForwardConfirmation()
  @Serializable @SerialName("filesMissing") data class FilesMissing(val filesCount: Int) : ForwardConfirmation()
  @Serializable @SerialName("filesFailed") data class FilesFailed(val filesCount: Int) : ForwardConfirmation()
}

@Serializable
data class GroupInfo (
  val groupId: Long,
  override val localDisplayName: String,
  val groupProfile: GroupProfile,
  val businessChat: BusinessChatInfo? = null,
  val fullGroupPreferences: FullGroupPreferences,
  val membership: GroupMember,
  val chatSettings: ChatSettings,
  override val createdAt: Instant,
  override val updatedAt: Instant,
  val chatTs: Instant?,
  val preparedGroup: PreparedGroup?,
  val uiThemes: ThemeModeOverrides? = null,
  val membersRequireAttention: Int,
  val chatTags: List<Long>,
  val chatItemTTL: Long?,
  override val localAlias: String,
): SomeChat, NamedChat {
  override val chatType get() = ChatType.Group
  override val id get() = "#$groupId"
  override val apiId get() = groupId
  override val ready get() = membership.memberActive
  override val nextConnect get() = nextConnectPrepared
  override val nextConnectPrepared = if (preparedGroup != null) !preparedGroup.connLinkStartedConnection else false
  override val profileChangeProhibited get() = preparedGroup?.connLinkPreparedConnection ?: false
  override val chatDeleted get() = false
  override val incognito get() = membership.memberIncognito
  override fun featureEnabled(feature: ChatFeature) = when (feature) {
    ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
    ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
    ChatFeature.Reactions -> fullGroupPreferences.reactions.on
    ChatFeature.Voice -> fullGroupPreferences.voice.on(membership)
    ChatFeature.Files -> fullGroupPreferences.files.on(membership)
    ChatFeature.Calls -> false
  }
  override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
  override val displayName get() = localAlias.ifEmpty { groupProfile.displayName }
  override val fullName get() = groupProfile.fullName
  override val shortDescr get() = groupProfile.shortDescr
  override val image get() = groupProfile.image

  val isOwner: Boolean
    get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent

  val canDelete: Boolean
    get() = membership.memberRole == GroupMemberRole.Owner || !membership.memberCurrentOrPending

  val canAddMembers: Boolean
    get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive

  val canModerate: Boolean
    get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive

  val chatIconName: ImageResource
    get() = when (businessChat?.chatType) {
      null -> MR.images.ic_supervised_user_circle_filled
      BusinessChatType.Business -> MR.images.ic_work_filled_padded
      BusinessChatType.Customer -> MR.images.ic_account_circle_filled
    }

  fun groupFeatureEnabled(feature: GroupFeature): Boolean {
    val p = fullGroupPreferences
    return when (feature) {
      GroupFeature.TimedMessages -> p.timedMessages.on
      GroupFeature.DirectMessages -> p.directMessages.on(membership)
      GroupFeature.FullDelete -> p.fullDelete.on
      GroupFeature.Reactions -> p.reactions.on
      GroupFeature.Voice -> p.voice.on(membership)
      GroupFeature.Files -> p.files.on(membership)
      GroupFeature.SimplexLinks -> p.simplexLinks.on(membership)
      GroupFeature.Reports -> p.reports.on
      GroupFeature.History -> p.history.on
    }
  }

  companion object {
    val sampleData = GroupInfo(
      groupId = 1,
      localDisplayName = "team",
      groupProfile = GroupProfile.sampleData,
      fullGroupPreferences = FullGroupPreferences.sampleData,
      membership = GroupMember.sampleData,
      chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false),
      createdAt = Clock.System.now(),
      updatedAt = Clock.System.now(),
      chatTs = Clock.System.now(),
      preparedGroup = null,
      uiThemes = null,
      membersRequireAttention = 0,
      chatTags = emptyList(),
      localAlias = "",
      chatItemTTL = null
    )
  }
}

@Serializable
data class PreparedGroup (
  val connLinkToConnect: CreatedConnLink,
  val connLinkPreparedConnection: Boolean,
  val connLinkStartedConnection: Boolean
)

@Serializable
data class GroupRef(val groupId: Long, val localDisplayName: String)

@Serializable
data class GroupProfile (
  override val displayName: String,
  override val fullName: String,
  override val shortDescr: String?,
  val description: String? = null,
  override val image: String? = null,
  override val localAlias: String = "",
  val groupPreferences: GroupPreferences? = null,
  val memberAdmission: GroupMemberAdmission? = null
): NamedChat {
  companion object {
    val sampleData = GroupProfile(
      displayName = "team",
      fullName = "My Team",
      shortDescr = null,
    )
  }
}

@Serializable
data class GroupMemberAdmission(
  val review: MemberCriteria? = null,
) {
  companion object {
    val sampleData = GroupMemberAdmission(
      review = null,
    )
  }
}

@Serializable
enum class MemberCriteria {
  @SerialName("all") All;

  val text: String
    get() = when(this) {
      MemberCriteria.All -> generalGetString(MR.strings.member_criteria_all)
    }
}

@Serializable
data class ContactShortLinkData (
  val profile: Profile,
  val message: MsgContent?,
  val business: Boolean
)

@Serializable
data class GroupShortLinkData (
  val groupProfile: GroupProfile
)

@Serializable
data class BusinessChatInfo (
  val chatType: BusinessChatType,
  val businessId: String,
  val customerId: String,
)

@Serializable
enum class BusinessChatType {
  @SerialName("business") Business,
  @SerialName("customer") Customer,
}

@Serializable
data class GroupMember (
  val groupMemberId: Long,
  val groupId: Long,
  val memberId: String,
  val memberRole: GroupMemberRole,
  val memberCategory: GroupMemberCategory,
  val memberStatus: GroupMemberStatus,
  val memberSettings: GroupMemberSettings,
  val blockedByAdmin: Boolean,
  val invitedBy: InvitedBy,
  val localDisplayName: String,
  val memberProfile: LocalProfile,
  val memberContactId: Long? = null,
  val memberContactProfileId: Long,
  var activeConn: Connection? = null,
  val supportChat: GroupSupportChat? = null,
  val memberChatVRange: VersionRange
): NamedChat {
  val id: String get() = "#$groupId @$groupMemberId"
  val ready get() = activeConn?.connStatus == ConnStatus.Ready
  val sndReady get() = ready || activeConn?.connStatus == ConnStatus.SndReady
  val sendMsgEnabled get() =
    sndReady
        && memberCurrent
        && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?: false)
        && !(activeConn?.connDisabled ?: true)
  override val displayName: String
    get() {
      val p = memberProfile
      val name = p.localAlias.ifEmpty { p.displayName }
      return unknownMember(name)
    }
  override val fullName: String get() = memberProfile.fullName
  override val shortDescr: String? get() = memberProfile.shortDescr
  override val image: String? get() = memberProfile.image
  val contactLink: String? = memberProfile.contactLink
  val verified get() = activeConn?.connectionCode != null
  val blocked get() = blockedByAdmin || !memberSettings.showMessages

  override val localAlias: String = memberProfile.localAlias

  override val chatViewName: String
    get() {
      val p = memberProfile
      val name = p.localAlias.ifEmpty { p.displayName + (if (p.fullName == "" || p.fullName == p.displayName) "" else " / ${p.fullName}") }
      return unknownMember(name)
    }

  private fun unknownMember(name: String): String {
    return if (memberStatus == GroupMemberStatus.MemUnknown) {
      if (memberId.startsWith(name)) {
        // unknown member was created using memberId for name
        String.format(generalGetString(MR.strings.past_member_vName), name)
      } else {
        // unknown member was created with name
        name
      }
    } else
      name
  }

  val localAliasAndFullName: String
    get() {
      val p = memberProfile
      val fullName = p.displayName + (if (p.fullName == "" || p.fullName == p.displayName) "" else " / ${p.fullName}")

      val name = if (p.localAlias.isNotEmpty()) {
        "${p.localAlias} ($fullName)"
      } else {
        fullName
      }
      return unknownMember(name)
    }

  val memberActive: Boolean get() = when (this.memberStatus) {
    GroupMemberStatus.MemRejected -> false
    GroupMemberStatus.MemRemoved -> false
    GroupMemberStatus.MemLeft -> false
    GroupMemberStatus.MemGroupDeleted -> false
    GroupMemberStatus.MemUnknown -> false
    GroupMemberStatus.MemInvited -> false
    GroupMemberStatus.MemPendingApproval -> true
    GroupMemberStatus.MemPendingReview -> true
    GroupMemberStatus.MemIntroduced -> false
    GroupMemberStatus.MemIntroInvited -> false
    GroupMemberStatus.MemAccepted -> false
    GroupMemberStatus.MemAnnounced -> false
    GroupMemberStatus.MemConnected -> true
    GroupMemberStatus.MemComplete -> true
    GroupMemberStatus.MemCreator -> true
  }

  val memberCurrent: Boolean get() = when (this.memberStatus) {
    GroupMemberStatus.MemRejected -> false
    GroupMemberStatus.MemRemoved -> false
    GroupMemberStatus.MemLeft -> false
    GroupMemberStatus.MemGroupDeleted -> false
    GroupMemberStatus.MemUnknown -> false
    GroupMemberStatus.MemInvited -> false
    GroupMemberStatus.MemPendingApproval -> false
    GroupMemberStatus.MemPendingReview -> false
    GroupMemberStatus.MemIntroduced -> true
    GroupMemberStatus.MemIntroInvited -> true
    GroupMemberStatus.MemAccepted -> true
    GroupMemberStatus.MemAnnounced -> true
    GroupMemberStatus.MemConnected -> true
    GroupMemberStatus.MemComplete -> true
    GroupMemberStatus.MemCreator -> true
  }

  val memberPending: Boolean get() = when (this.memberStatus) {
    GroupMemberStatus.MemPendingApproval -> true
    GroupMemberStatus.MemPendingReview -> true
    else -> false
  }

  val memberCurrentOrPending: Boolean get() =
    memberCurrent || memberPending

  fun canBeRemoved(groupInfo: GroupInfo): Boolean {
    val userRole = groupInfo.membership.memberRole
    return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft
        && userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberActive
  }

  fun canChangeRoleTo(groupInfo: GroupInfo): List<GroupMemberRole>? =
    if (!canBeRemoved(groupInfo) || memberPending) null
    else groupInfo.membership.memberRole.let { userRole ->
      GroupMemberRole.selectableRoles.filter { it <= userRole }
    }

  fun canBlockForAll(groupInfo: GroupInfo): Boolean {
    val userRole = groupInfo.membership.memberRole
    return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft && memberRole < GroupMemberRole.Moderator
        && userRole >= GroupMemberRole.Moderator && userRole >= memberRole && groupInfo.membership.memberActive
        && !memberPending
  }

  val supportChatNotRead: Boolean get() =
    if (supportChat != null)
      supportChat.memberAttention > 0 || supportChat.mentions > 0 || supportChat.unread > 0
    else
      false

  val versionRange: VersionRange = activeConn?.peerChatVRange ?: memberChatVRange

  val memberIncognito = memberProfile.profileId != memberContactProfileId

  companion object {
    val sampleData = GroupMember(
      groupMemberId = 1,
      groupId = 1,
      memberId = "abcd",
      memberRole = GroupMemberRole.Member,
      memberCategory = GroupMemberCategory.InviteeMember,
      memberStatus = GroupMemberStatus.MemComplete,
      memberSettings = GroupMemberSettings(showMessages = true),
      blockedByAdmin = false,
      invitedBy = InvitedBy.IBUser(),
      localDisplayName = "alice",
      memberProfile = LocalProfile.sampleData,
      memberContactId = 1,
      memberContactProfileId = 1L,
      activeConn = Connection.sampleData,
      memberChatVRange = VersionRange(minVersion = 1, maxVersion = 15)
    )
  }
}

@Serializable
class GroupSupportChat (
  val chatTs: Instant,
  val unread: Int,
  val memberAttention: Int,
  val mentions: Int
)

@Serializable
data class GroupMemberSettings(val showMessages: Boolean) {}

@Serializable
data class GroupMemberRef(
  val groupMemberId: Long,
  val profile: Profile
)

@Serializable
data class GroupMemberIds(
  val groupMemberId: Long,
  val groupId: Long
)

@Serializable
enum class GroupMemberRole(val memberRole: String) {
  @SerialName("observer") Observer("observer"), // order matters in comparisons
  @SerialName("author") Author("author"),
  @SerialName("member") Member("member"),
  @SerialName("moderator") Moderator("moderator"),
  @SerialName("admin") Admin("admin"),
  @SerialName("owner") Owner("owner");

  companion object {
    val selectableRoles: List<GroupMemberRole> = listOf(Observer, Member, Moderator, Admin, Owner)
  }

  val text: String get() = when (this) {
    Observer -> generalGetString(MR.strings.group_member_role_observer)
    Author -> generalGetString(MR.strings.group_member_role_author)
    Member -> generalGetString(MR.strings.group_member_role_member)
    Moderator -> generalGetString(MR.strings.group_member_role_moderator)
    Admin -> generalGetString(MR.strings.group_member_role_admin)
    Owner -> generalGetString(MR.strings.group_member_role_owner)
  }
}

@Serializable
enum class GroupMemberCategory {
  @SerialName("user") UserMember,
  @SerialName("invitee") InviteeMember,
  @SerialName("host") HostMember,
  @SerialName("pre") PreMember,
  @SerialName("post") PostMember;
}

@Serializable
enum class GroupMemberStatus {
  @SerialName("rejected") MemRejected,
  @SerialName("removed") MemRemoved,
  @SerialName("left") MemLeft,
  @SerialName("deleted") MemGroupDeleted,
  @SerialName("unknown") MemUnknown,
  @SerialName("invited") MemInvited,
  @SerialName("pending_approval") MemPendingApproval,
  @SerialName("pending_review") MemPendingReview,
  @SerialName("introduced") MemIntroduced,
  @SerialName("intro-inv") MemIntroInvited,
  @SerialName("accepted") MemAccepted,
  @SerialName("announced") MemAnnounced,
  @SerialName("connected") MemConnected,
  @SerialName("complete") MemComplete,
  @SerialName("creator") MemCreator;

  val text: String get() = when (this) {
    MemRejected -> generalGetString(MR.strings.group_member_status_rejected)
    MemRemoved -> generalGetString(MR.strings.group_member_status_removed)
    MemLeft -> generalGetString(MR.strings.group_member_status_left)
    MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted)
    MemUnknown -> generalGetString(MR.strings.group_member_status_unknown)
    MemInvited -> generalGetString(MR.strings.group_member_status_invited)
    MemPendingApproval -> generalGetString(MR.strings.group_member_status_pending_approval)
    MemPendingReview -> generalGetString(MR.strings.group_member_status_pending_review)
    MemIntroduced -> generalGetString(MR.strings.group_member_status_introduced)
    MemIntroInvited -> generalGetString(MR.strings.group_member_status_intro_invitation)
    MemAccepted -> generalGetString(MR.strings.group_member_status_accepted)
    MemAnnounced -> generalGetString(MR.strings.group_member_status_announced)
    MemConnected -> generalGetString(MR.strings.group_member_status_connected)
    MemComplete -> generalGetString(MR.strings.group_member_status_complete)
    MemCreator -> generalGetString(MR.strings.group_member_status_creator)
  }

  val shortText: String get() = when (this) {
    MemRejected -> generalGetString(MR.strings.group_member_status_rejected)
    MemRemoved -> generalGetString(MR.strings.group_member_status_removed)
    MemLeft -> generalGetString(MR.strings.group_member_status_left)
    MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted)
    MemUnknown -> generalGetString(MR.strings.group_member_status_unknown_short)
    MemInvited -> generalGetString(MR.strings.group_member_status_invited)
    MemPendingApproval -> generalGetString(MR.strings.group_member_status_pending_approval_short)
    MemPendingReview -> generalGetString(MR.strings.group_member_status_pending_review_short)
    MemIntroduced -> generalGetString(MR.strings.group_member_status_connecting)
    MemIntroInvited -> generalGetString(MR.strings.group_member_status_connecting)
    MemAccepted -> generalGetString(MR.strings.group_member_status_connecting)
    MemAnnounced -> generalGetString(MR.strings.group_member_status_connecting)
    MemConnected -> generalGetString(MR.strings.group_member_status_connected)
    MemComplete -> generalGetString(MR.strings.group_member_status_complete)
    MemCreator -> generalGetString(MR.strings.group_member_status_creator)
  }
}

@Serializable
sealed class InvitedBy {
  @Serializable @SerialName("contact") class IBContact(val byContactId: Long): InvitedBy()
  @Serializable @SerialName("user") class IBUser: InvitedBy()
  @Serializable @SerialName("unknown") class IBUnknown: InvitedBy()
}

@Serializable
class LinkPreview (
  val uri: String,
  val title: String,
  val description: String,
  val image: String
) {
  companion object {
    val sampleData = LinkPreview(
      uri = "https://www.duckduckgo.com",
      title = "Privacy, simplified.",
      description = "The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.",
      image = ""
    )
  }
}

@Serializable
class MemberSubError (
  val member: GroupMemberIds,
  val memberError: ChatError
)

@Serializable
class NoteFolder(
  val noteFolderId: Long,
  val favorite: Boolean,
  val unread: Boolean,
  override val createdAt: Instant,
  override val updatedAt: Instant,
  val chatTs: Instant
): SomeChat, NamedChat {
  override val chatType get() = ChatType.Local
  override val id get() = "*$noteFolderId"
  override val apiId get() = noteFolderId
  override val chatDeleted get() = false
  override val ready get() = true
  override val nextConnect get() = false
  override val nextConnectPrepared get() = false
  override val profileChangeProhibited get() = false
  override val incognito get() = false
  override fun featureEnabled(feature: ChatFeature) = when (feature) {
    ChatFeature.TimedMessages -> false
    ChatFeature.FullDelete -> false
    ChatFeature.Reactions -> false
    ChatFeature.Voice -> true
    ChatFeature.Files -> true
    ChatFeature.Calls -> false
  }
  override val timedMessagesTTL: Int? get() = null
  override val displayName get() = generalGetString(MR.strings.note_folder_local_display_name)
  override val fullName get() = ""
  override val shortDescr get() = null
  override val image get() = null
  override val localAlias get() = ""
  override val localDisplayName: String get() = ""

  companion object {
    val sampleData = NoteFolder(
      noteFolderId = 1,
      favorite = false,
      unread = false,
      createdAt = Clock.System.now(),
      updatedAt = Clock.System.now(),
      chatTs = Clock.System.now()
    )
  }
}

@Serializable
class UserContactRequest (
  val contactRequestId: Long,
  val cReqChatVRange: VersionRange,
  override val localDisplayName: String,
  val profile: Profile,
  override val createdAt: Instant,
  override val updatedAt: Instant
): SomeChat, NamedChat {
  override val chatType get() = ChatType.ContactRequest
  override val id get() = contactRequestChatId(contactRequestId)
  override val apiId get() = contactRequestId
  override val chatDeleted get() = false
  override val ready get() = true
  override val nextConnect get() = false
  override val nextConnectPrepared get() = false
  override val profileChangeProhibited get() = false
  override val incognito get() = false
  override fun featureEnabled(feature: ChatFeature) = false
  override val timedMessagesTTL: Int? get() = null
  override val displayName get() = profile.displayName
  override val fullName get() = profile.fullName
  override val shortDescr get() = profile.shortDescr
  override val image get() = profile.image
  override val localAlias get() = ""

  companion object {
    val sampleData = UserContactRequest(
      contactRequestId = 1,
      cReqChatVRange = VersionRange(1, 1),
      localDisplayName = "alice",
      profile = Profile.sampleData,
      createdAt = Clock.System.now(),
      updatedAt = Clock.System.now()
    )
  }
}

fun contactRequestChatId(contactRequestId: Long): String = "<@$contactRequestId"

@Serializable
class PendingContactConnection(
  val pccConnId: Long,
  val pccAgentConnId: String,
  val pccConnStatus: ConnStatus,
  val viaContactUri: Boolean,
  val groupLinkId: String? = null,
  val customUserProfileId: Long? = null,
  val connLinkInv: CreatedConnLink? = null,
  override val localAlias: String,
  override val createdAt: Instant,
  override val updatedAt: Instant
): SomeChat, NamedChat {
  override val chatType get() = ChatType.ContactConnection
  override val id get () = ":$pccConnId"
  override val apiId get() = pccConnId
  override val chatDeleted get() = false
  override val ready get() = false
  override val nextConnect get() = false
  override val nextConnectPrepared get() = false
  override val profileChangeProhibited get() = false
  override val incognito get() = customUserProfileId != null
  override fun featureEnabled(feature: ChatFeature) = false
  override val timedMessagesTTL: Int? get() = null
  override val localDisplayName get() = String.format(generalGetString(MR.strings.connection_local_display_name), pccConnId)
  override val displayName: String get() {
    if (localAlias.isNotEmpty()) return localAlias
    val initiated = pccConnStatus.initiated
    return if (initiated == null) {
      // this should not be in the chat list
      generalGetString(MR.strings.display_name_connection_established)
    } else {
      generalGetString(
        if (viaContactUri) MR.strings.display_name_requested_to_connect
        else if (initiated) MR.strings.display_name_invited_to_connect
        else MR.strings.display_name_accepted_invitation
      )
    }
  }
  override val fullName get() = ""
  override val shortDescr get() = null
  override val image get() = null

  val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri

  val description: String get() {
    val initiated = pccConnStatus.initiated
    return if (initiated == null) "" else generalGetString(
      if (initiated && !viaContactUri)
        if (incognito) MR.strings.description_you_shared_one_time_link_incognito else MR.strings.description_you_shared_one_time_link
      else if (viaContactUri)
        if (groupLinkId != null)
          if (incognito) MR.strings.description_via_group_link_incognito else MR.strings.description_via_group_link
        else
          if (incognito) MR.strings.description_via_contact_address_link_incognito else MR.strings.description_via_contact_address_link
      else
        if (incognito) MR.strings.description_via_one_time_link_incognito else MR.strings.description_via_one_time_link
    )
  }

  companion object {
    fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): PendingContactConnection =
      PendingContactConnection(
        pccConnId = 1,
        pccAgentConnId = "abcd",
        pccConnStatus = status,
        viaContactUri = viaContactUri,
        localAlias = "",
        customUserProfileId = null,
        createdAt = Clock.System.now(),
        updatedAt = Clock.System.now()
      )
  }
}

@Serializable
enum class ConnStatus {
  @SerialName("new") New,
  @SerialName("prepared") Prepared,
  @SerialName("joined") Joined,
  @SerialName("requested") Requested,
  @SerialName("accepted") Accepted,
  @SerialName("snd-ready") SndReady,
  @SerialName("ready") Ready,
  @SerialName("deleted") Deleted;

  val initiated: Boolean? get() = when (this) {
    New -> true
    Prepared -> false
    Joined -> false
    Requested -> true
    Accepted -> true
    SndReady -> null
    Ready -> null
    Deleted -> null
  }
}

@Serializable
data class ChatItemDeletion (
  val deletedChatItem: AChatItem,
  val toChatItem: AChatItem? = null
)

@Serializable
class AChatItem (
  val chatInfo: ChatInfo,
  val chatItem: ChatItem
)

@Serializable
class ACIReaction(
  val chatInfo: ChatInfo,
  val chatReaction: CIReaction
)

@Serializable
data class MemberReaction(
  val groupMember: GroupMember,
  val reactionTs: Instant
)

@Serializable
data class CIMentionMember(
  val groupMemberId: Long,
  val displayName: String,
  val localAlias: String?,
  val memberRole: GroupMemberRole
)

@Serializable
data class CIMention(
  val memberId: String,
  val memberRef: CIMentionMember?
) {
  constructor(groupMember: GroupMember): this(
    groupMember.memberId,
    CIMentionMember(
      groupMember.groupMemberId,
      groupMember.memberProfile.displayName,
      groupMember.memberProfile.localAlias,
      groupMember.memberRole
    )
  )
}

@Serializable
class CIReaction(
  val chatDir: CIDirection,
  val chatItem: ChatItem,
  val sentAt: Instant,
  val reaction: MsgReaction
)

@Serializable @Stable
data class ChatItem (
  val chatDir: CIDirection,
  val meta: CIMeta,
  val content: CIContent,
  val formattedText: List<FormattedText>? = null,
  val mentions: Map<String, CIMention>? = null,
  val quotedItem: CIQuote? = null,
  val reactions: List<CIReactionCount>,
  val file: CIFile? = null
) {
  val id: Long get() = meta.itemId
  val timestampText: String get() = meta.timestampText

  val text: String get() {
    val mc = content.msgContent
    return when {
      content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(MR.strings.voice_message_with_duration), durationText(mc.duration))
      content.text == "" && file != null -> file.fileName
      else -> content.text
    }
  }

  val isRcvNew: Boolean get() = meta.isRcvNew

  val allowAddReaction: Boolean get() =
    meta.itemDeleted == null && !isLiveDummy && (reactions.count { it.userReacted } < 3)

  val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID

  val encryptedFile: Boolean? = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null

  val memberDisplayName: String? get() =
    when (chatDir) {
      is CIDirection.GroupRcv -> when (content) {
        is CIContent.RcvGroupEventContent -> when (val event = content.rcvGroupEvent) {
          is RcvGroupEvent.MemberProfileUpdated -> {
            val to = event.toProfile
            val from = event.fromProfile
            when {
              to.displayName != from.displayName || to.fullName != from.fullName -> null
              else -> chatDir.groupMember.chatViewName
            }
          }

          else -> chatDir.groupMember.chatViewName
        }

        else -> chatDir.groupMember.chatViewName
      }

      else -> null
    }

  val localNote: Boolean = chatDir is CIDirection.LocalSnd || chatDir is CIDirection.LocalRcv

  val isDeletedContent: Boolean get() =
    when (content) {
      is CIContent.SndDeleted -> true
      is CIContent.RcvDeleted -> true
      is CIContent.SndModerated -> true
      is CIContent.RcvModerated -> true
      is CIContent.RcvBlocked -> true
      else -> false
    }

  val memberConnected: GroupMember? get() =
    when (chatDir) {
      is CIDirection.GroupRcv -> when (content) {
        is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
          is RcvGroupEvent.MemberConnected -> chatDir.groupMember
          else -> null
        }
        else -> null
      }
      else -> null
    }

  val mergeCategory: CIMergeCategory?
    get() = when (content) {
      is CIContent.RcvChatFeature,
      is CIContent.SndChatFeature,
      is CIContent.RcvGroupFeature,
      is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature
      is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
        is RcvGroupEvent.UserRole,
        is RcvGroupEvent.UserDeleted,
        is RcvGroupEvent.GroupDeleted,
        is RcvGroupEvent.MemberCreatedContact,
        is RcvGroupEvent.NewMemberPendingReview ->
          null
        else -> CIMergeCategory.RcvGroupEvent
      }
      is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) {
        is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.MemberAccepted, is SndGroupEvent.UserPendingReview -> null
        else -> CIMergeCategory.SndGroupEvent
      }
      else -> {
        if (meta.itemDeleted == null) {
          null
        } else {
          if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted
        }
      }
    }

  fun memberToModerate(chatInfo: ChatInfo): Pair<GroupInfo, GroupMember?>? {
    return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) {
      val m = chatInfo.groupInfo.membership
      if (m.memberRole >= GroupMemberRole.Moderator && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) {
        chatInfo.groupInfo to chatDir.groupMember
      } else {
      null
      }
    } else if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupSnd) {
      val m = chatInfo.groupInfo.membership
      if (m.memberRole >= GroupMemberRole.Moderator) {
        chatInfo.groupInfo to null
      } else {
        null
      }
    } else {
      null
    }
  }

  val showLocalDelete: Boolean
    get() = when (content) {
      is CIContent.SndDirectE2EEInfo -> false
      is CIContent.RcvDirectE2EEInfo -> false
      is CIContent.SndGroupE2EEInfo -> false
      is CIContent.RcvGroupE2EEInfo -> false
      is CIContent.ChatBanner -> false
      else -> true
    }

  val isReport: Boolean get() = when (content) {
    is CIContent.SndMsgContent, is CIContent.RcvMsgContent ->
      content.msgContent is MsgContent.MCReport
    else -> false
  }

  val isActiveReport: Boolean get() = isReport && !isDeletedContent && meta.itemDeleted == null

  val canBeDeletedForSelf: Boolean
    get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete

  val showNotification: Boolean get() =
    when (content) {
      is CIContent.SndMsgContent -> false
      is CIContent.RcvMsgContent -> meta.itemDeleted == null
      is CIContent.SndDeleted -> false
      is CIContent.RcvDeleted -> false
      is CIContent.SndCall -> false
      is CIContent.RcvCall -> false // notification is shown on CallInvitation instead
      is CIContent.RcvIntegrityError -> false
      is CIContent.RcvDecryptionError -> false
      is CIContent.RcvGroupInvitation -> true
      is CIContent.SndGroupInvitation -> false
      is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) {
        is RcvDirectEvent.ContactDeleted -> false
        is RcvDirectEvent.ProfileUpdated -> false
        is RcvDirectEvent.GroupInvLinkReceived -> true
      }
      is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
        is RcvGroupEvent.MemberAdded -> false
        is RcvGroupEvent.MemberConnected -> false
        is RcvGroupEvent.MemberAccepted -> false
        is RcvGroupEvent.UserAccepted -> false
        is RcvGroupEvent.MemberLeft -> false
        is RcvGroupEvent.MemberRole -> false
        is RcvGroupEvent.MemberBlocked -> false
        is RcvGroupEvent.UserRole -> true
        is RcvGroupEvent.MemberDeleted -> false
        is RcvGroupEvent.UserDeleted -> true
        is RcvGroupEvent.GroupDeleted -> true
        is RcvGroupEvent.GroupUpdated -> false
        is RcvGroupEvent.InvitedViaGroupLink -> false
        is RcvGroupEvent.MemberCreatedContact -> false
        is RcvGroupEvent.MemberProfileUpdated -> false
        is RcvGroupEvent.NewMemberPendingReview -> true
      }
      is CIContent.SndGroupEventContent -> false
      is CIContent.RcvConnEventContent -> false
      is CIContent.SndConnEventContent -> false
      is CIContent.RcvChatFeature -> false
      is CIContent.SndChatFeature -> false
      is CIContent.RcvChatPreference -> false
      is CIContent.SndChatPreference -> false
      is CIContent.RcvGroupFeature -> false
      is CIContent.SndGroupFeature -> false
      is CIContent.RcvChatFeatureRejected -> true
      is CIContent.RcvGroupFeatureRejected -> false
      is CIContent.SndModerated -> false
      is CIContent.RcvModerated -> false
      is CIContent.RcvBlocked -> false
      is CIContent.SndDirectE2EEInfo -> false
      is CIContent.RcvDirectE2EEInfo -> false
      is CIContent.SndGroupE2EEInfo -> false
      is CIContent.RcvGroupE2EEInfo -> false
      is CIContent.ChatBanner -> false
      is CIContent.InvalidJSON -> false
    }

  fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status))

  companion object {
    fun getSampleData(
      id: Long = 1,
      dir: CIDirection = CIDirection.DirectSnd(),
      ts: Instant = Clock.System.now(),
      text: String = "hello\nthere",
      status: CIStatus = CIStatus.SndNew(),
      sentViaProxy: Boolean? = null,
      quotedItem: CIQuote? = null,
      file: CIFile? = null,
      itemForwarded: CIForwardedFrom? = null,
      itemDeleted: CIDeleted? = null,
      itemEdited: Boolean = false,
      itemTimed: CITimed? = null,
      itemLive: Boolean = false,
      deletable: Boolean = true,
      editable: Boolean = true
    ) =
      ChatItem(
        chatDir = dir,
        meta = CIMeta.getSample(id, ts, text, status, sentViaProxy, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, deletable, editable),
        content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
        quotedItem = quotedItem,
        reactions = listOf(),
        file = file
      )

    fun getFileMsgContentSample(
      id: Long = 1,
      text: String = "",
      fileName: String = "test.txt",
      fileSize: Long = 100,
      fileStatus: CIFileStatus = CIFileStatus.RcvComplete
    ) =
      ChatItem(
        chatDir = CIDirection.DirectRcv(),
        meta = CIMeta.getSample(id, Clock.System.now(), text, CIStatus.RcvRead()),
        content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile(text)),
        quotedItem = null,
        reactions = listOf(),
        file = CIFile.getSample(fileName = fileName, fileSize = fileSize, fileStatus = fileStatus)
      )

    fun getDeletedContentSampleData(
      id: Long = 1,
      dir: CIDirection = CIDirection.DirectRcv(),
      ts: Instant = Clock.System.now(),
      text: String = "this item is deleted", // sample not localized
      status: CIStatus = CIStatus.RcvRead()
    ) =
      ChatItem(
        chatDir = dir,
        meta = CIMeta.getSample(id, ts, text, status),
        content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
        quotedItem = null,
        reactions = listOf(),
        file = null
      )

    fun getGroupInvitationSample(status: CIGroupInvitationStatus = CIGroupInvitationStatus.Pending) =
      ChatItem(
        chatDir = CIDirection.DirectRcv(),
        meta = CIMeta.getSample(1, Clock.System.now(), "received invitation to join group team as admin", CIStatus.RcvRead()),
        content = CIContent.RcvGroupInvitation(groupInvitation = CIGroupInvitation.getSample(status = status), memberRole = GroupMemberRole.Admin),
        quotedItem = null,
        reactions = listOf(),
        file = null
      )

    fun getGroupEventSample() =
      ChatItem(
        chatDir = CIDirection.DirectRcv(),
        meta = CIMeta.getSample(1, Clock.System.now(), "group event text", CIStatus.RcvRead()),
        content = CIContent.RcvGroupEventContent(rcvGroupEvent = RcvGroupEvent.MemberAdded(groupMemberId = 1, profile = Profile.sampleData)),
        quotedItem = null,
        reactions = listOf(),
        file = null
      )

    fun getChatFeatureSample(feature: ChatFeature, enabled: FeatureEnabled): ChatItem {
      val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled, param = null)
      return ChatItem(
        chatDir = CIDirection.DirectRcv(),
        meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead()),
        content = content,
        quotedItem = null,
        reactions = listOf(),
        file = null
      )
    }

    private const val TEMP_DELETED_CHAT_ITEM_ID = -1L
    const val TEMP_LIVE_CHAT_ITEM_ID = -2L

    val deletedItemDummy: ChatItem
      get() = ChatItem(
        chatDir = CIDirection.DirectRcv(),
        meta = CIMeta(
          itemId = TEMP_DELETED_CHAT_ITEM_ID,
          itemTs = Clock.System.now(),
          itemText = generalGetString(MR.strings.deleted_description),
          itemStatus = CIStatus.RcvRead(),
          sentViaProxy = null,
          createdAt = Clock.System.now(),
          updatedAt = Clock.System.now(),
          itemForwarded = null,
          itemDeleted = null,
          itemEdited = false,
          itemTimed = null,
          itemLive = false,
          deletable = false,
          editable = false,
          userMention = false,
          showGroupAsSender = false,
        ),
        content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
        quotedItem = null,
        reactions = listOf(),
        file = null
      )

    fun liveDummy(direct: Boolean): ChatItem = ChatItem(
        chatDir = if (direct) CIDirection.DirectSnd() else CIDirection.GroupSnd(),
        meta = CIMeta(
          itemId = TEMP_LIVE_CHAT_ITEM_ID,
          itemTs = Clock.System.now(),
          itemText = "",
          itemStatus = CIStatus.RcvRead(),
          sentViaProxy = null,
          createdAt = Clock.System.now(),
          updatedAt = Clock.System.now(),
          itemForwarded = null,
          itemDeleted = null,
          itemEdited = false,
          itemTimed = null,
          itemLive = true,
          deletable = false,
          editable = false,
          userMention = false,
          showGroupAsSender = false
        ),
        content = CIContent.SndMsgContent(MsgContent.MCText("")),
        quotedItem = null,
        reactions = listOf(),
        file = null
      )

    fun invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String): ChatItem =
      ChatItem(
        chatDir = chatDir ?: CIDirection.DirectSnd(),
        meta = meta ?: CIMeta.invalidJSON(),
        content = CIContent.InvalidJSON(json),
        quotedItem = null,
        reactions = listOf(),
        file = null
      )
  }
}

sealed class SecondaryContextFilter {
  class GroupChatScopeContext(val groupScopeInfo: GroupChatScopeInfo): SecondaryContextFilter()
  class MsgContentTagContext(val contentTag: MsgContentTag): SecondaryContextFilter()
}

fun MutableState<SnapshotStateList<Chat>>.add(index: Int, elem: Chat) {
  value = SnapshotStateList<Chat>().apply { addAll(value); add(index, elem) }
}

fun MutableState<SnapshotStateList<Chat>>.add(elem: Chat) {
  value = SnapshotStateList<Chat>().apply { addAll(value); add(elem) }
}

// For some reason, Kotlin version crashes if the list is empty
fun <T> MutableList<T>.removeAll(predicate: (T) -> Boolean): Boolean = if (isEmpty()) false else remAll(predicate)

fun <T> MutableState<SnapshotStateList<T>>.addAll(index: Int, elems: List<T>) {
  value = SnapshotStateList<T>().apply { addAll(value); addAll(index, elems) }
}

fun <T> MutableState<SnapshotStateList<T>>.addAll(elems: List<T>) {
  value = SnapshotStateList<T>().apply { addAll(value); addAll(elems) }
}

fun MutableState<SnapshotStateList<Chat>>.removeAll(block: (Chat) -> Boolean) {
  value = SnapshotStateList<Chat>().apply { addAll(value); removeAll(block) }
}

// TODO [contexts] operates with both contexts?
// Removes item(s) from chatItems and notifies a listener about removed item(s)
fun MutableState<SnapshotStateList<ChatItem>>.removeAllAndNotify(block: (ChatItem) -> Boolean) {
  val toRemove = ArrayList<Triple<Long, Int, Boolean>>()
  value = SnapshotStateList<ChatItem>().apply {
    addAll(value)
    var i = 0
    removeAll {
      val remove = block(it)
      if (remove) toRemove.add(Triple(it.id, i, it.isRcvNew))
      i++
      remove
    }
  }
  if (toRemove.isNotEmpty()) {
    chatModel.chatsContext.chatState.itemsRemoved(toRemove, value)
    chatModel.secondaryChatsContext.value?.chatState?.itemsRemoved(toRemove, value)
  }
}

fun MutableState<SnapshotStateList<Chat>>.removeAt(index: Int): Chat {
  val new = SnapshotStateList<Chat>()
  new.addAll(value)
  val res = new.removeAt(index)
  value = new
  return res
}

fun <T> MutableState<SnapshotStateList<T>>.replaceAll(elems: List<T>) {
  value = SnapshotStateList<T>().apply { addAll(elems) }
}

fun MutableState<SnapshotStateList<Chat>>.clear() {
  value = SnapshotStateList()
}

// TODO [contexts] operates with both contexts?
// Removes all chatItems and notifies a listener about it
fun MutableState<SnapshotStateList<ChatItem>>.clearAndNotify() {
  value = SnapshotStateList()
  chatModel.chatsContext.chatState.clear()
  chatModel.secondaryChatsContext.value?.chatState?.clear()
}

fun <T> State<SnapshotStateList<T>>.asReversed(): MutableList<T> = value.asReversed()

fun <T> State<SnapshotStateList<T>>.toList(): List<T> = value.toList()

operator fun <T> State<SnapshotStateList<T>>.get(i: Int): T = value[i]

operator fun <T> State<SnapshotStateList<T>>.set(index: Int, elem: T) { value[index] = elem }

val State<List<Any>>.size: Int get() = value.size

enum class CIMergeCategory {
  MemberConnected,
  RcvGroupEvent,
  SndGroupEvent,
  SndItemDeleted,
  RcvItemDeleted,
  ChatFeature,
}

@Serializable
sealed class CIDirection {
  @Serializable @SerialName("directSnd") class DirectSnd: CIDirection()
  @Serializable @SerialName("directRcv") class DirectRcv: CIDirection()
  @Serializable @SerialName("groupSnd") class GroupSnd: CIDirection()
  @Serializable @SerialName("groupRcv") class GroupRcv(val groupMember: GroupMember): CIDirection()
  @Serializable @SerialName("localSnd") class LocalSnd: CIDirection()
  @Serializable @SerialName("localRcv") class LocalRcv: CIDirection()

  val sent: Boolean get() = when(this) {
    is DirectSnd -> true
    is DirectRcv -> false
    is GroupSnd -> true
    is GroupRcv -> false
    is LocalSnd -> true
    is LocalRcv -> false
  }
}

@Serializable
data class CIMeta (
  val itemId: Long,
  val itemTs: Instant,
  val itemText: String,
  val itemStatus: CIStatus,
  val sentViaProxy: Boolean?,
  val createdAt: Instant,
  val updatedAt: Instant,
  val itemForwarded: CIForwardedFrom?,
  val itemDeleted: CIDeleted?,
  val itemEdited: Boolean,
  val itemTimed: CITimed?,
  val itemLive: Boolean?,
  val userMention: Boolean,
  val deletable: Boolean,
  val editable: Boolean,
  val showGroupAsSender: Boolean
) {
  val timestampText: String get() = getTimestampText(itemTs, true)

  val recent: Boolean get() = updatedAt + 10.toDuration(DurationUnit.SECONDS) > Clock.System.now()
  val isLive: Boolean get() = itemLive == true
  val disappearing: Boolean get() = !isRcvNew && itemTimed?.deleteAt != null

  val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew

  fun statusIcon(
    primaryColor: Color,
    metaColor: Color = CurrentColors.value.colors.secondary,
    paleMetaColor: Color = CurrentColors.value.colors.secondary
  ): Pair<ImageResource, Color>? =
    itemStatus.statusIcon(primaryColor, metaColor, paleMetaColor)

  companion object {
    fun getSample(
      id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(), sentViaProxy: Boolean? = null,
      itemForwarded: CIForwardedFrom? = null, itemDeleted: CIDeleted? = null, itemEdited: Boolean = false,
      itemTimed: CITimed? = null, itemLive: Boolean = false, deletable: Boolean = true, editable: Boolean = true
    ): CIMeta =
      CIMeta(
        itemId = id,
        itemTs = ts,
        itemText = text,
        itemStatus = status,
        sentViaProxy = sentViaProxy,
        createdAt = ts,
        updatedAt = ts,
        itemForwarded = itemForwarded,
        itemDeleted = itemDeleted,
        itemEdited = itemEdited,
        itemTimed = itemTimed,
        itemLive = itemLive,
        deletable = deletable,
        editable = editable,
        userMention = false,
        showGroupAsSender = false
      )

    fun invalidJSON(): CIMeta =
      CIMeta(
        // itemId can not be the same for different items, otherwise ChatView will crash
        itemId = Random.nextLong(-1000000L, -1000L),
        itemTs = Clock.System.now(),
        itemText = "invalid JSON",
        itemStatus = CIStatus.SndNew(),
        sentViaProxy = null,
        createdAt = Clock.System.now(),
        updatedAt = Clock.System.now(),
        itemForwarded = null,
        itemDeleted = null,
        itemEdited = false,
        itemTimed = null,
        itemLive = false,
        deletable = false,
        editable = false,
        userMention = false,
        showGroupAsSender = false
      )
  }
}

@Serializable
data class CITimed(
  val ttl: Int,
  val deleteAt: Instant?
)

fun getTimestampDateText(t: Instant): String {
  val tz = TimeZone.currentSystemDefault()
  val time = t.toLocalDateTime(tz).toJavaLocalDateTime()
  val weekday = time.format(DateTimeFormatter.ofPattern("EEE"))
  val dayMonthYear = time.format(DateTimeFormatter.ofPattern(
    if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM yyyy")
  )

  return "$weekday, $dayMonthYear"
}

fun getTimestampText(t: Instant, shortFormat: Boolean = false): String {
  val tz = TimeZone.currentSystemDefault()
  val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz)
  val time: LocalDateTime = t.toLocalDateTime(tz)
  val period = now.date.minus(time.date)
  val recent = now.date == time.date ||
      (period.years == 0 && period.months == 0 && period.days == 1 && now.hour < 12 && time.hour >= 18 )
  val dateFormatter =
    if (recent || shortFormat) {
      DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
    } else {
      val dayMonthFormat = when (Locale.getDefault().country) {
        "US" -> "M/dd"
        "DE" -> "dd.MM"
        "RU" -> "dd.MM"
        else -> "dd/MM"
      }
      val dayMonthYearFormat = when (Locale.getDefault().country) {
        "US" -> "M/dd/yy"
        "DE" -> "dd.MM.yy"
        "RU" -> "dd.MM.yy"
        else -> "dd/MM/yy"
      }
      DateTimeFormatter.ofPattern(
       if (now.year == time.year) dayMonthFormat else dayMonthYearFormat
      )
//      DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
    }
  return time.toJavaLocalDateTime().format(dateFormatter)
}

fun localTimestamp(t: Instant): String {
  val tz = TimeZone.currentSystemDefault()
  val ts: LocalDateTime = t.toLocalDateTime(tz)
  val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
  return ts.toJavaLocalDateTime().format(dateFormatter)
}

fun localDate(t: Instant): String {
  val tz = TimeZone.currentSystemDefault()
  val ts: LocalDateTime = t.toLocalDateTime(tz)
  val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
  return ts.toJavaLocalDateTime().format(dateFormatter)
}

@Serializable
sealed class CIStatus {
  @Serializable @SerialName("sndNew") class SndNew: CIStatus()
  @Serializable @SerialName("sndSent") class SndSent(val sndProgress: SndCIStatusProgress): CIStatus()
  @Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus, val sndProgress: SndCIStatusProgress): CIStatus()
  @Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
  @Serializable @SerialName("sndError") class CISSndError(val agentError: SndError): CIStatus()
  @Serializable @SerialName("sndWarning") class SndWarning(val agentError: SndError): CIStatus()
  @Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
  @Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
  @Serializable @SerialName("invalid") class Invalid(val text: String): CIStatus()

  // as in corresponds to SENT response from agent
  fun isSent(): Boolean = when(this) {
    is SndNew -> false
    is SndSent -> true
    is SndRcvd -> false
    is SndErrorAuth -> true
    is CISSndError -> true
    is SndWarning -> true
    is RcvNew -> false
    is RcvRead -> false
    is Invalid -> false
  }

  fun statusIcon(
    primaryColor: Color,
    metaColor: Color = CurrentColors.value.colors.secondary,
    paleMetaColor: Color = CurrentColors.value.colors.secondary
  ): Pair<ImageResource, Color>? =
    when (this) {
      is SndNew -> null
      is SndSent -> when (this.sndProgress) {
        SndCIStatusProgress.Complete -> MR.images.ic_check_filled to metaColor
        SndCIStatusProgress.Partial -> MR.images.ic_check_filled to paleMetaColor
      }
      is SndRcvd -> when(this.msgRcptStatus) {
        MsgReceiptStatus.Ok -> when (this.sndProgress) {
          SndCIStatusProgress.Complete -> MR.images.ic_double_check to metaColor
          SndCIStatusProgress.Partial -> MR.images.ic_double_check to paleMetaColor
        }
        MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red
      }
      is SndErrorAuth -> MR.images.ic_close to Color.Red
      is CISSndError -> MR.images.ic_close to Color.Red
      is SndWarning -> MR.images.ic_warning_filled to WarningOrange
      is RcvNew -> MR.images.ic_circle_filled to primaryColor
      is RcvRead -> null
      is CIStatus.Invalid -> MR.images.ic_question_mark to metaColor
    }

  val statusInto: Pair<String, String>? get() = when (this) {
    is SndNew -> null
    is SndSent -> null
    is SndRcvd -> null
    is SndErrorAuth -> generalGetString(MR.strings.message_delivery_error_title) to generalGetString(MR.strings.message_delivery_error_desc)
    is CISSndError -> generalGetString(MR.strings.message_delivery_error_title) to agentError.errorInfo
    is SndWarning -> generalGetString(MR.strings.message_delivery_warning_title) to agentError.errorInfo
    is RcvNew -> null
    is RcvRead -> null
    is Invalid -> "Invalid status" to this.text
  }
}

fun shouldKeepOldSndCIStatus(oldStatus: CIStatus, newStatus: CIStatus): Boolean =
  when {
    oldStatus is CIStatus.SndRcvd && newStatus !is CIStatus.SndRcvd -> true
    oldStatus.isSent() && newStatus is CIStatus.SndNew -> true
    else -> false
  }

@Serializable
sealed class SndError {
  @Serializable @SerialName("auth") class Auth: SndError()
  @Serializable @SerialName("quota") class Quota: SndError()
  @Serializable @SerialName("expired") class Expired: SndError()
  @Serializable @SerialName("relay") class Relay(val srvError: SrvError): SndError()
  @Serializable @SerialName("proxy") class Proxy(val proxyServer: String, val srvError: SrvError): SndError()
  @Serializable @SerialName("proxyRelay") class ProxyRelay(val proxyServer: String, val srvError: SrvError): SndError()
  @Serializable @SerialName("other") class Other(val sndError: String): SndError()

  val errorInfo: String get() = when (this) {
    is SndError.Auth -> generalGetString(MR.strings.snd_error_auth)
    is SndError.Quota -> generalGetString(MR.strings.snd_error_quota)
    is SndError.Expired -> generalGetString(MR.strings.snd_error_expired)
    is SndError.Relay -> generalGetString(MR.strings.snd_error_relay).format(srvError.errorInfo)
    is SndError.Proxy -> generalGetString(MR.strings.snd_error_proxy).format(proxyServer, srvError.errorInfo)
    is SndError.ProxyRelay -> generalGetString(MR.strings.snd_error_proxy_relay).format(proxyServer, srvError.errorInfo)
    is SndError.Other -> generalGetString(MR.strings.ci_status_other_error).format(sndError)
  }
}

@Serializable
sealed class SrvError {
  @Serializable @SerialName("host") class Host: SrvError()
  @Serializable @SerialName("version") class Version: SrvError()
  @Serializable @SerialName("other") class Other(val srvError: String): SrvError()

  val errorInfo: String get() = when (this) {
    is SrvError.Host -> generalGetString(MR.strings.srv_error_host)
    is SrvError.Version -> generalGetString(MR.strings.srv_error_version)
    is SrvError.Other -> srvError
  }
}

@Serializable
enum class MsgReceiptStatus {
  @SerialName("ok") Ok,
  @SerialName("badMsgHash") BadMsgHash;
}

@Serializable
enum class SndCIStatusProgress {
  @SerialName("partial") Partial,
  @SerialName("complete") Complete;
}

@Serializable
sealed class GroupSndStatus {
  @Serializable @SerialName("new") class New: GroupSndStatus()
  @Serializable @SerialName("forwarded") class Forwarded: GroupSndStatus()
  @Serializable @SerialName("inactive") class Inactive: GroupSndStatus()
  @Serializable @SerialName("sent") class Sent: GroupSndStatus()
  @Serializable @SerialName("rcvd") class Rcvd(val msgRcptStatus: MsgReceiptStatus): GroupSndStatus()
  @Serializable @SerialName("error") class Error(val agentError: SndError): GroupSndStatus()
  @Serializable @SerialName("warning") class Warning(val agentError: SndError): GroupSndStatus()
  @Serializable @SerialName("invalid") class Invalid(val text: String): GroupSndStatus()

  fun statusIcon(
    primaryColor: Color,
    metaColor: Color = CurrentColors.value.colors.secondary,
    paleMetaColor: Color = CurrentColors.value.colors.secondary
  ): Pair<ImageResource, Color> =
    when (this) {
      is New -> MR.images.ic_more_horiz to metaColor
      is Forwarded -> MR.images.ic_chevron_right_2 to metaColor
      is Inactive -> MR.images.ic_person_off to metaColor
      is Sent -> MR.images.ic_check_filled to metaColor
      is Rcvd -> when(this.msgRcptStatus) {
        MsgReceiptStatus.Ok -> MR.images.ic_double_check to metaColor
        MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red
      }
      is Error -> MR.images.ic_close to Color.Red
      is Warning -> MR.images.ic_warning_filled to WarningOrange
      is Invalid -> MR.images.ic_question_mark to metaColor
    }

  val statusInto: Pair<String, String>? get() = when (this) {
    is New -> null
    is Forwarded -> generalGetString(MR.strings.message_forwarded_title) to generalGetString(MR.strings.message_forwarded_desc)
    is Inactive -> generalGetString(MR.strings.member_inactive_title) to generalGetString(MR.strings.member_inactive_desc)
    is Sent -> null
    is Rcvd -> null
    is Error -> generalGetString(MR.strings.message_delivery_error_title) to agentError.errorInfo
    is Warning -> generalGetString(MR.strings.message_delivery_warning_title) to agentError.errorInfo
    is Invalid -> "Invalid status" to this.text
  }
}

@Serializable
sealed class CIDeleted {
  @Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted()
  @Serializable @SerialName("blocked") class Blocked(val deletedTs: Instant?): CIDeleted()
  @Serializable @SerialName("blockedByAdmin") class BlockedByAdmin(val deletedTs: Instant?): CIDeleted()
  @Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted()
}

@Serializable
enum class MsgDirection {
  @SerialName("rcv") Rcv,
  @SerialName("snd") Snd;
}

@Serializable
sealed class CIForwardedFrom {
  @Serializable @SerialName("unknown") object Unknown: CIForwardedFrom()
  @Serializable @SerialName("contact") class Contact(override val chatName: String, val msgDir: MsgDirection, val contactId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom()
  @Serializable @SerialName("group") class Group(override val chatName: String, val msgDir: MsgDirection, val groupId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom()

  open val chatName: String
    get() = when (this) {
        Unknown -> ""
        is Contact -> chatName
        is Group -> chatName
      }

  val chatTypeApiIdMsgId: Triple<ChatType, Long, Long?>?
    get() = when (this) {
      Unknown -> null
      is Contact -> if (contactId != null) Triple(ChatType.Direct, contactId, chatItemId) else null
      is Group -> if (groupId != null) Triple(ChatType.Group, groupId, chatItemId) else null
    }

  fun text(chatType: ChatType): String =
    if (chatType == ChatType.Local) {
      if (chatName.isEmpty()) {
        generalGetString(MR.strings.saved_description)
      } else {
        generalGetString(MR.strings.saved_from_description).format(chatName)
      }
    } else {
      generalGetString(MR.strings.forwarded_description)
    }
}

@Serializable
enum class CIDeleteMode(val deleteMode: String) {
  @SerialName("internal") cidmInternal("internal"),
  @SerialName("internalMark") cidmInternalMark("internalMark"),
  @SerialName("broadcast") cidmBroadcast("broadcast");
}

interface ItemContent {
  val text: String
}

@Serializable
sealed class CIContent: ItemContent {
  abstract val msgContent: MsgContent?

  @Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent()
  @Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent()
  // legacy - since v4.3.0 itemDeleted field is used
  @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
  // legacy - since v4.3.0 itemDeleted field is used
  @Serializable @SerialName("rcvDeleted")  class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvDirectEvent") class RcvDirectEventContent(val rcvDirectEvent: RcvDirectEvent): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("sndConnEvent") class SndConnEventContent(val sndConnEvent: SndConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvChatPreference") class RcvChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("sndChatPreference") class SndChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null, val memberRole_: GroupMemberRole?): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null, val memberRole_: GroupMemberRole?): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("sndModerated") object SndModerated: CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvModerated") object RcvModerated: CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvBlocked") object RcvBlocked: CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("sndDirectE2EEInfo") class SndDirectE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvDirectE2EEInfo") class RcvDirectE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("sndGroupE2EEInfo") class SndGroupE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("rcvGroupE2EEInfo") class RcvGroupE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("chatBanner") object ChatBanner: CIContent() { override val msgContent: MsgContent? get() = null }
  @Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null }

  override val text: String get() = when (this) {
      is SndMsgContent -> msgContent.text
      is RcvMsgContent -> msgContent.text
      is SndDeleted -> generalGetString(MR.strings.deleted_description)
      is RcvDeleted -> generalGetString(MR.strings.deleted_description)
      is SndCall -> status.text(duration)
      is RcvCall -> status.text(duration)
      is RcvIntegrityError -> msgError.text
      is RcvDecryptionError -> msgDecryptError.text
      is RcvGroupInvitation -> groupInvitation.text
      is SndGroupInvitation -> groupInvitation.text
      is RcvDirectEventContent -> rcvDirectEvent.text
      is RcvGroupEventContent -> rcvGroupEvent.text
      is SndGroupEventContent -> sndGroupEvent.text
      is RcvConnEventContent -> rcvConnEvent.text
      is SndConnEventContent -> sndConnEvent.text
      is RcvChatFeature -> featureText(feature, enabled.text, param)
      is SndChatFeature -> featureText(feature, enabled.text, param)
      is RcvChatPreference -> preferenceText(feature, allowed, param)
      is SndChatPreference -> preferenceText(feature, allowed, param)
      is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param, memberRole_)
      is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param, memberRole_)
      is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(MR.strings.feature_received_prohibited)}"
      is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(MR.strings.feature_received_prohibited)}"
      is SndModerated -> generalGetString(MR.strings.moderated_description)
      is RcvModerated -> generalGetString(MR.strings.moderated_description)
      is RcvBlocked -> generalGetString(MR.strings.blocked_by_admin_item_description)
      is SndDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo)
      is RcvDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo)
      is SndGroupE2EEInfo -> e2eeInfoNoPQStr
      is RcvGroupE2EEInfo -> e2eeInfoNoPQStr
      is ChatBanner -> ""
      is InvalidJSON -> "invalid data"
    }

  val hasMsgContent: Boolean get() =
    if (msgContent != null) {
      (msgContent as MsgContent).text.trim().isNotEmpty()
    } else {
      false
    }

  val showMemberName: Boolean get() =
    when (this) {
      is RcvMsgContent -> true
      is RcvDeleted -> true
      is RcvCall -> true
      is RcvIntegrityError -> true
      is RcvDecryptionError -> true
      is RcvGroupInvitation -> true
      is RcvModerated -> true
      is RcvBlocked -> true
      is InvalidJSON -> true
      else -> false
    }

  companion object {
    fun directE2EEInfoStr(e2EEInfo: E2EEInfo): String =
      if (e2EEInfo.pqEnabled == true) {
        generalGetString(MR.strings.e2ee_info_pq_short)
      } else {
        e2eeInfoNoPQStr
      }

    private val e2eeInfoNoPQStr: String = generalGetString(MR.strings.e2ee_info_no_pq_short)

    fun featureText(feature: Feature, enabled: String, param: Int?, role: GroupMemberRole? = null): String =
      (if (feature.hasParam) {
        "${feature.text}: ${timeText(param)}"
      } else {
        "${feature.text}: $enabled"
      }) + (
          if (feature.hasRole && role != null)
            " (${roleText(role)})"
          else
            ""
          )

    private fun roleText(role: GroupMemberRole?): String =
      when (role) {
        GroupMemberRole.Owner -> generalGetString(MR.strings.feature_roles_owners)
        GroupMemberRole.Admin -> generalGetString(MR.strings.feature_roles_admins)
        GroupMemberRole.Moderator -> generalGetString(MR.strings.feature_roles_moderators)
        else -> generalGetString(MR.strings.feature_roles_all_members)
      }

    fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when {
      allowed != FeatureAllowed.NO && feature.hasParam && param != null ->
        String.format(generalGetString(MR.strings.feature_offered_item_with_param), feature.text, timeText(param))
      allowed != FeatureAllowed.NO ->
        String.format(generalGetString(MR.strings.feature_offered_item), feature.text, timeText(param))
      else ->
        String.format(generalGetString(MR.strings.feature_cancelled_item), feature.text, timeText(param))
    }
  }
}

@Serializable
enum class MsgDecryptError {
  @SerialName("ratchetHeader") RatchetHeader,
  @SerialName("tooManySkipped") TooManySkipped,
  @SerialName("ratchetEarlier") RatchetEarlier,
  @SerialName("other") Other,
  @SerialName("ratchetSync") RatchetSync;

  val text: String get() = when (this) {
    RatchetHeader -> generalGetString(MR.strings.decryption_error)
    TooManySkipped -> generalGetString(MR.strings.decryption_error)
    RatchetEarlier -> generalGetString(MR.strings.decryption_error)
    Other -> generalGetString(MR.strings.decryption_error)
    RatchetSync -> generalGetString(MR.strings.encryption_renegotiation_error)
  }
}

@Serializable
class CIQuote (
  val chatDir: CIDirection? = null,
  val itemId: Long? = null,
  val sharedMsgId: String? = null,
  val sentAt: Instant,
  val content: MsgContent,
  val formattedText: List<FormattedText>? = null
): ItemContent {
  override val text: String by lazy {
    if (content.text == "" && content is MsgContent.MCVoice)
      durationText(content.duration)
    else
      content.text
  }


  fun sender(membership: GroupMember?): String? = when (chatDir) {
    is CIDirection.DirectSnd -> generalGetString(MR.strings.sender_you_pronoun)
    is CIDirection.DirectRcv -> null
    is CIDirection.GroupSnd -> membership?.displayName ?: generalGetString(MR.strings.sender_you_pronoun)
    is CIDirection.GroupRcv -> chatDir.groupMember.displayName
    is CIDirection.LocalSnd -> generalGetString(MR.strings.sender_you_pronoun)
    is CIDirection.LocalRcv -> null
    null -> null
  }

  companion object {
    fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote =
      CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text))
  }
}

@Serializable
class CIReactionCount(val reaction: MsgReaction, val userReacted: Boolean, val totalReacted: Int)

@Serializable(with = MsgReactionSerializer::class)
sealed class MsgReaction {
  @Serializable(with = MsgReactionSerializer::class) class Emoji(val emoji: MREmojiChar): MsgReaction()
  @Serializable(with = MsgReactionSerializer::class) class Unknown(val type: String? = null, val json: JsonElement): MsgReaction()

  val text: String get() = when (this) {
    is Emoji -> when (emoji) {
      MREmojiChar.Heart -> "❤️"
      else -> emoji.value
    }
    is Unknown -> "?"
  }

  companion object {
    val values: List<MsgReaction> get() = MREmojiChar.entries.map(::Emoji)
    val supported: List<MsgReaction> get() = listOf(
      MREmojiChar.ThumbsUp,
      MREmojiChar.ThumbsDown,
      MREmojiChar.Smile,
      MREmojiChar.Laugh,
      MREmojiChar.Sad,
      MREmojiChar.Heart,
      MREmojiChar.Launch,
      MREmojiChar.Check
    ).map(::Emoji)
  }
}

object MsgReactionSerializer : KSerializer<MsgReaction> {
  override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgReaction", PolymorphicKind.SEALED) {
    element("Emoji", buildClassSerialDescriptor("Emoji") {
      element<String>("emoji")
    })
    element("Unknown", buildClassSerialDescriptor("Unknown"))
  }

  override fun deserialize(decoder: Decoder): MsgReaction {
    require(decoder is JsonDecoder)
    val json = decoder.decodeJsonElement()
    return if (json is JsonObject && "type" in json) {
      when(val t = json["type"]?.jsonPrimitive?.content ?: "") {
        "emoji" -> {
          val msgReaction = try {
            val emoji = Json.decodeFromString<MREmojiChar>(json["emoji"].toString())
            MsgReaction.Emoji(emoji)
          } catch (e: Throwable) {
            MsgReaction.Unknown(t, json)
          }
          msgReaction
        }
        else -> MsgReaction.Unknown(t, json)
      }
    } else {
      MsgReaction.Unknown("", json)
    }
  }

  override fun serialize(encoder: Encoder, value: MsgReaction) {
    require(encoder is JsonEncoder)
    val json = when (value) {
      is MsgReaction.Emoji ->
        buildJsonObject {
          put("type", "emoji")
          put("emoji", json.encodeToJsonElement(value.emoji))
        }
      is MsgReaction.Unknown -> value.json
    }
    encoder.encodeJsonElement(json)
  }
}

@Serializable
enum class MREmojiChar(val value: String) {
  @SerialName("👍") ThumbsUp("👍"),
  @SerialName("👎") ThumbsDown("👎"),
  @SerialName("😀") Smile("😀"),
  @SerialName("😂") Laugh("😂"),
  @SerialName("😢") Sad("😢"),
  @SerialName("❤") Heart("❤"),
  @SerialName("🚀") Launch("🚀"),
  @SerialName("✅") Check("✅");
}

@Serializable
data class CIFile(
  val fileId: Long,
  val fileName: String,
  val fileSize: Long,
  val fileSource: CryptoFile? = null,
  val fileStatus: CIFileStatus,
  val fileProtocol: FileProtocol
) {
  val loaded: Boolean = when (fileStatus) {
    is CIFileStatus.SndStored -> true
    is CIFileStatus.SndTransfer -> true
    is CIFileStatus.SndComplete -> true
    is CIFileStatus.SndCancelled -> true
    is CIFileStatus.SndError -> true
    is CIFileStatus.SndWarning -> true
    is CIFileStatus.RcvInvitation -> false
    is CIFileStatus.RcvAccepted -> false
    is CIFileStatus.RcvTransfer -> false
    is CIFileStatus.RcvAborted -> false
    is CIFileStatus.RcvCancelled -> false
    is CIFileStatus.RcvComplete -> true
    is CIFileStatus.RcvError -> false
    is CIFileStatus.RcvWarning -> false
    is CIFileStatus.Invalid -> false
  }

  @Transient
  val cancelAction: CancelAction? = when (fileStatus) {
    is CIFileStatus.SndStored -> sndCancelAction
    is CIFileStatus.SndTransfer -> sndCancelAction
    is CIFileStatus.SndComplete ->
      if (fileProtocol == FileProtocol.XFTP) {
        revokeCancelAction
      } else {
        null
      }
    is CIFileStatus.SndCancelled -> null
    is CIFileStatus.SndError -> null
    is CIFileStatus.SndWarning -> sndCancelAction
    is CIFileStatus.RcvInvitation -> null
    is CIFileStatus.RcvAccepted -> rcvCancelAction
    is CIFileStatus.RcvTransfer -> rcvCancelAction
    is CIFileStatus.RcvAborted -> null
    is CIFileStatus.RcvCancelled -> null
    is CIFileStatus.RcvComplete -> null
    is CIFileStatus.RcvError -> null
    is CIFileStatus.RcvWarning -> rcvCancelAction
    is CIFileStatus.Invalid -> null
  }

  val showStatusIconInSmallView: Boolean = when (fileStatus) {
    is CIFileStatus.SndStored -> fileProtocol != FileProtocol.LOCAL
    is CIFileStatus.SndTransfer -> true
    is CIFileStatus.SndComplete -> false
    is CIFileStatus.SndCancelled -> true
    is CIFileStatus.SndError -> true
    is CIFileStatus.SndWarning -> true
    is CIFileStatus.RcvInvitation -> false
    is CIFileStatus.RcvAccepted -> true
    is CIFileStatus.RcvTransfer -> true
    is CIFileStatus.RcvAborted -> true
    is CIFileStatus.RcvCancelled -> true
    is CIFileStatus.RcvComplete -> false
    is CIFileStatus.RcvError -> true
    is CIFileStatus.RcvWarning -> true
    is CIFileStatus.Invalid -> true
  }

  /**
   * DO NOT CALL this function in compose scope, [LaunchedEffect], [DisposableEffect] and so on. Only with [withBGApi] or [runBlocking].
   * Otherwise, it will be canceled when moving to another screen/item/view, etc
   * */
  suspend fun loadRemoteFile(allowToShowAlert: Boolean): Boolean {
    val rh = chatModel.currentRemoteHost.value
    val user = chatModel.currentUser.value
    if (rh == null || user == null || fileSource == null || !loaded) return false
    if (getLoadedFilePath(this) != null) return true
    if (cachedRemoteFileRequests.contains(fileSource)) return false

    val rf = RemoteFile(
      userId = user.userId,
      fileId = fileId,
      sent = fileStatus.sent,
      fileSource = fileSource
    )
    cachedRemoteFileRequests[fileSource] = false
    val showAlert = fileSize > 5_000_000 && allowToShowAlert
    if (showAlert) {
      AlertManager.shared.showAlertMsgWithProgress(
        title = generalGetString(MR.strings.loading_remote_file_title),
        text = generalGetString(MR.strings.loading_remote_file_desc)
      )
    }
    val res = chatModel.controller.getRemoteFile(rh.remoteHostId, rf)
    cachedRemoteFileRequests[fileSource] = res
    if (showAlert) {
      AlertManager.shared.hideAlert()
    }
    return res
  }

  fun forwardingAllowed(): Boolean = when {
    chatModel.connectedToRemote() && cachedRemoteFileRequests[fileSource] != false && loaded -> true
    getLoadedFilePath(this) != null -> true
    else -> false
  }

  companion object {
    fun getSample(
      fileId: Long = 1,
      fileName: String = "test.txt",
      fileSize: Long = 100,
      filePath: String? = "test.txt",
      fileStatus: CIFileStatus = CIFileStatus.RcvComplete
    ): CIFile =
      CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)

    val cachedRemoteFileRequests = SnapshotStateMap<CryptoFile, Boolean>()
  }
}

@Serializable
data class CryptoFile(
  val filePath: String,
  val cryptoArgs: CryptoFileArgs?
) {

  val isAbsolutePath: Boolean
    get() = File(filePath).isAbsolute

  @Transient
  private var tmpFile: File? = null

  fun createTmpFileIfNeeded(): File {
    if (tmpFile == null) {
      val tmpFile = File(tmpDir, UUID.randomUUID().toString())
      tmpFile.deleteOnExit()
      ChatModel.filesToDelete.add(tmpFile)
      this.tmpFile = tmpFile
    }
    return tmpFile!!
  }

  fun deleteTmpFile() {
    tmpFile?.delete()
  }

  private fun decryptToTmpFile(): URI? {
    val absoluteFilePath = if (isAbsolutePath) filePath else getAppFilePath(filePath)
    val tmpFile = createTmpFileIfNeeded()
    try {
      decryptCryptoFile(absoluteFilePath, cryptoArgs ?: return null, tmpFile.absolutePath)
    } catch (e: Exception) {
      Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString())
      AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.error), text = e.stackTraceToString())
      return null
    }
    return tmpFile.toURI()
  }

  fun decryptedGet(): URI? {
    val decrypted = decryptedUris[filePath]
    return if (decrypted != null && decrypted.toFile().exists()) {
      decrypted
    } else {
      null
    }
  }

  fun decryptedGetOrCreate(): URI? {
    val decrypted = decryptedGet() ?: decryptToTmpFile()
    if (decrypted != null) {
      decryptedUris[filePath] = decrypted
    }
    return decrypted
  }

  companion object {
    fun plain(f: String): CryptoFile = CryptoFile(f, null)

    fun desktopPlain(f: URI): CryptoFile = CryptoFile(f.toFile().absolutePath, null)

    private val decryptedUris = mutableMapOf<String, URI>()
  }
}

@Serializable
data class CryptoFileArgs(val fileKey: String, val fileNonce: String)

class CancelAction(
  val uiActionId: StringResource,
  val alert: AlertInfo
)

class AlertInfo(
  val titleId: StringResource,
  val messageId: StringResource,
  val confirmId: StringResource
)

private val sndCancelAction: CancelAction = CancelAction(
  uiActionId = MR.strings.stop_file__action,
  alert = AlertInfo(
    titleId = MR.strings.stop_snd_file__title,
    messageId = MR.strings.stop_snd_file__message,
    confirmId = MR.strings.stop_file__confirm
  )
)
private val revokeCancelAction: CancelAction = CancelAction(
  uiActionId = MR.strings.revoke_file__action,
  alert = AlertInfo(
    titleId = MR.strings.revoke_file__title,
    messageId = MR.strings.revoke_file__message,
    confirmId = MR.strings.revoke_file__confirm
  )
)
private val rcvCancelAction: CancelAction = CancelAction(
  uiActionId = MR.strings.stop_file__action,
  alert = AlertInfo(
    titleId = MR.strings.stop_rcv_file__title,
    messageId = MR.strings.stop_rcv_file__message,
    confirmId = MR.strings.stop_file__confirm
  )
)

@Serializable
enum class FileProtocol {
  @SerialName("smp") SMP,
  @SerialName("xftp") XFTP,
  @SerialName("local") LOCAL;
}

@Serializable
sealed class CIFileStatus {
  @Serializable @SerialName("sndStored") object SndStored: CIFileStatus()
  @Serializable @SerialName("sndTransfer") class SndTransfer(val sndProgress: Long, val sndTotal: Long): CIFileStatus()
  @Serializable @SerialName("sndComplete") object SndComplete: CIFileStatus()
  @Serializable @SerialName("sndCancelled") object SndCancelled: CIFileStatus()
  @Serializable @SerialName("sndError") class SndError(val sndFileError: FileError): CIFileStatus()
  @Serializable @SerialName("sndWarning") class SndWarning(val sndFileError: FileError): CIFileStatus()
  @Serializable @SerialName("rcvInvitation") object RcvInvitation: CIFileStatus()
  @Serializable @SerialName("rcvAccepted") object RcvAccepted: CIFileStatus()
  @Serializable @SerialName("rcvTransfer") class RcvTransfer(val rcvProgress: Long, val rcvTotal: Long): CIFileStatus()
  @Serializable @SerialName("rcvAborted") object RcvAborted: CIFileStatus()
  @Serializable @SerialName("rcvComplete") object RcvComplete: CIFileStatus()
  @Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus()
  @Serializable @SerialName("rcvError") class RcvError(val rcvFileError: FileError): CIFileStatus()
  @Serializable @SerialName("rcvWarning") class RcvWarning(val rcvFileError: FileError): CIFileStatus()
  @Serializable @SerialName("invalid") class Invalid(val text: String): CIFileStatus()

  val sent: Boolean get() = when (this) {
    is SndStored -> true
    is SndTransfer -> true
    is SndComplete -> true
    is SndCancelled -> true
    is SndError -> true
    is SndWarning -> true
    is RcvInvitation -> false
    is RcvAccepted -> false
    is RcvTransfer -> false
    is RcvAborted -> false
    is RcvComplete -> false
    is RcvCancelled -> false
    is RcvError -> false
    is RcvWarning -> false
    is Invalid -> false
  }
}

@Serializable
sealed class FileError {
  @Serializable @SerialName("auth") class Auth: FileError()
  @Serializable @SerialName("blocked") class Blocked(val server: String, val blockInfo: BlockingInfo): FileError()
  @Serializable @SerialName("noFile") class NoFile: FileError()
  @Serializable @SerialName("relay") class Relay(val srvError: SrvError): FileError()
  @Serializable @SerialName("other") class Other(val fileError: String): FileError()

  val errorInfo: String get() = when (this) {
    is Auth -> generalGetString(MR.strings.file_error_auth)
    is Blocked -> generalGetString(MR.strings.file_error_blocked).format(blockInfo.reason.text)
    is NoFile -> generalGetString(MR.strings.file_error_no_file)
    is Relay -> generalGetString(MR.strings.file_error_relay).format(srvError.errorInfo)
    is Other -> generalGetString(MR.strings.ci_status_other_error).format(fileError)
  }

  val moreInfoButton: Pair<String, String>? get() = when(this) {
    is Blocked -> generalGetString(MR.strings.how_it_works) to contentModerationPostLink
    else -> null
  }
}

@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@Serializable(with = MsgContentSerializer::class)
sealed class MsgContent {
  abstract val text: String

  @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
  @Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
  @Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
  @Serializable(with = MsgContentSerializer::class) class MCVideo(override val text: String, val image: String, val duration: Int): MsgContent()
  @Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
  @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
  @Serializable(with = MsgContentSerializer::class) class MCReport(override val text: String, val reason: ReportReason): MsgContent()
  @Serializable(with = MsgContentSerializer::class) class MCChat(override val text: String, val chatLink: MsgChatLink): MsgContent()
  @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()

  val isVoice: Boolean get() =
    when (this) {
      is MCVoice -> true
      else -> false
    }

  val isMediaOrFileAttachment: Boolean get() =
    when (this) {
      is MCImage -> true
      is MCVideo -> true
      is MCFile -> true
      else -> false
    }

  val cmdString: String get() =
    if (this is MCUnknown) "json $json" else "json ${json.encodeToString(this)}"
}

@Serializable
class CIGroupInvitation (
  val groupId: Long,
  val groupMemberId: Long,
  val localDisplayName: String,
  val groupProfile: GroupProfile,
  val status: CIGroupInvitationStatus,
  ) {
  val text: String get() = String.format(
    generalGetString(MR.strings.group_invitation_item_description),
    groupProfile.displayName)

  companion object {
    fun getSample(
      groupId: Long = 1,
      groupMemberId: Long = 1,
      localDisplayName: String = "team",
      groupProfile: GroupProfile = GroupProfile.sampleData,
      status: CIGroupInvitationStatus = CIGroupInvitationStatus.Pending
    ): CIGroupInvitation =
      CIGroupInvitation(groupId = groupId, groupMemberId = groupMemberId, localDisplayName = localDisplayName, groupProfile = groupProfile, status = status)
  }
}

@Serializable
enum class CIGroupInvitationStatus {
  @SerialName("pending") Pending,
  @SerialName("accepted") Accepted,
  @SerialName("rejected") Rejected,
  @SerialName("expired") Expired;
}

@Serializable
class E2EEInfo (val pqEnabled: Boolean?) {}

object MsgContentSerializer : KSerializer<MsgContent> {
  override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) {
    element("MCText", buildClassSerialDescriptor("MCText") {
      element<String>("text")
    })
    element("MCLink", buildClassSerialDescriptor("MCLink") {
      element<String>("text")
      element<String>("preview")
    })
    element("MCImage", buildClassSerialDescriptor("MCImage") {
      element<String>("text")
      element<String>("image")
    })
    element("MCVideo", buildClassSerialDescriptor("MCVideo") {
      element<String>("text")
      element<String>("image")
      element<Int>("duration")
    })
    element("MCFile", buildClassSerialDescriptor("MCFile") {
      element<String>("text")
    })
    element("MCReport", buildClassSerialDescriptor("MCReport") {
      element<String>("text")
      element<ReportReason>("reason")
    })
    element("MCChat", buildClassSerialDescriptor("MCChat") {
      element<String>("text")
      element<MsgChatLink>("chatLink")
    })
    element("MCUnknown", buildClassSerialDescriptor("MCUnknown"))
  }

  override fun deserialize(decoder: Decoder): MsgContent {
    require(decoder is JsonDecoder)
    val json = decoder.decodeJsonElement()
    return if (json is JsonObject) {
      if ("type" in json) {
        val t = json["type"]?.jsonPrimitive?.content ?: ""
        val text = json["text"]?.jsonPrimitive?.content ?: generalGetString(MR.strings.unknown_message_format)
        when (t) {
          "text" -> MsgContent.MCText(text)
          "link" -> {
            val preview = Json.decodeFromString<LinkPreview>(json["preview"].toString())
            MsgContent.MCLink(text, preview)
          }
          "image" -> {
            val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
            MsgContent.MCImage(text, image)
          }
          "video" -> {
            val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
            val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
            MsgContent.MCVideo(text, image, duration)
          }
          "voice" -> {
            val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
            MsgContent.MCVoice(text, duration)
          }
          "file" -> MsgContent.MCFile(text)
          "report" -> {
            val reason = Json.decodeFromString<ReportReason>(json["reason"].toString())
            MsgContent.MCReport(text, reason)
          }
          "chat" -> {
            val chatLink = Json.decodeFromString<MsgChatLink>(json["chatLink"].toString())
            MsgContent.MCChat(text, chatLink)
          }
          else -> MsgContent.MCUnknown(t, text, json)
        }
      } else {
        MsgContent.MCUnknown(text = generalGetString(MR.strings.invalid_message_format), json = json)
      }
    } else {
      MsgContent.MCUnknown(text = generalGetString(MR.strings.invalid_message_format), json = json)
    }
  }

  override fun serialize(encoder: Encoder, value: MsgContent) {
    require(encoder is JsonEncoder)
    val json = when (value) {
      is MsgContent.MCText ->
        buildJsonObject {
          put("type", "text")
          put("text", value.text)
        }
      is MsgContent.MCLink ->
        buildJsonObject {
          put("type", "link")
          put("text", value.text)
          put("preview", json.encodeToJsonElement(value.preview))
        }
      is MsgContent.MCImage ->
        buildJsonObject {
          put("type", "image")
          put("text", value.text)
          put("image", value.image)
        }
      is MsgContent.MCVideo ->
        buildJsonObject {
          put("type", "video")
          put("text", value.text)
          put("image", value.image)
          put("duration", value.duration)
        }
      is MsgContent.MCVoice ->
        buildJsonObject {
          put("type", "voice")
          put("text", value.text)
          put("duration", value.duration)
        }
      is MsgContent.MCFile ->
        buildJsonObject {
          put("type", "file")
          put("text", value.text)
        }
      is MsgContent.MCReport ->
        buildJsonObject {
          put("type", "report")
          put("text", value.text)
          put("reason", json.encodeToJsonElement(value.reason))
        }
      is MsgContent.MCChat ->
        buildJsonObject {
          put("type", "chat")
          put("text", value.text)
          put("chatLink", json.encodeToJsonElement(value.chatLink))
        }
      is MsgContent.MCUnknown -> value.json
    }
    encoder.encodeJsonElement(json)
  }
}

@Serializable
enum class MsgContentTag {
  @SerialName("text") Text,
  @SerialName("link") Link,
  @SerialName("image") Image,
  @SerialName("video") Video,
  @SerialName("voice") Voice,
  @SerialName("file") File,
  @SerialName("report") Report,
  @SerialName("chat") Chat,
}

@Serializable
sealed class MsgChatLink {
  @Serializable @SerialName("contact") data class Contact(val connLink: String, val profile: Profile, val business: Boolean) : MsgChatLink()
  @Serializable @SerialName("invitation") data class Invitation(val invLink: String, val profile: Profile) : MsgChatLink()
  @Serializable @SerialName("group") data class Group(val connLink: String, val groupProfile: GroupProfile) : MsgChatLink()
}

@Serializable
class FormattedText(val text: String, val format: Format? = null) {
  val linkUri: String? get() =
    when (format) {
      is Format.Uri -> text
      is Format.HyperLink -> format.linkUri
      else -> null
    }

  companion object {
    fun plain(text: String): List<FormattedText> = if (text.isEmpty()) emptyList() else listOf(FormattedText(text))
  }
}

@Serializable
sealed class Format {
  @Serializable @SerialName("bold") class Bold: Format()
  @Serializable @SerialName("italic") class Italic: Format()
  @Serializable @SerialName("strikeThrough") class StrikeThrough: Format()
  @Serializable @SerialName("snippet") class Snippet: Format()
  @Serializable @SerialName("secret") class Secret: Format()
  @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
  @Serializable @SerialName("uri") class Uri: Format()
  @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format()
  @Serializable @SerialName("simplexLink") class SimplexLink(val showText: String?, val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List<String>): Format() {
    val simplexLinkText: String get() =
      "${linkType.description} $viaHosts"
    val viaHosts: String get() =
      "(${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})"
  }
  @Serializable @SerialName("command") class Command(val commandStr: String): Format()
  @Serializable @SerialName("mention") class Mention(val memberName: String): Format()
  @Serializable @SerialName("email") class Email: Format()
  @Serializable @SerialName("phone") class Phone: Format()
  @Serializable @SerialName("unknown") class Unknown: Format()

  val style: SpanStyle @Composable get() = when (this) {
    is Bold -> SpanStyle(fontWeight = FontWeight.Bold)
    is Italic -> SpanStyle(fontStyle = FontStyle.Italic)
    is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
    is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace)
    is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
    is Colored -> SpanStyle(color = this.color.uiColor)
    is Uri -> linkStyle
    is HyperLink -> linkStyle
    is SimplexLink -> linkStyle
    is Command -> SpanStyle(color = MaterialTheme.colors.primary, fontFamily = FontFamily.Monospace)
    is Mention -> SpanStyle(fontWeight = FontWeight.Medium)
    is Email -> linkStyle
    is Phone -> linkStyle
    is Unknown -> SpanStyle()
  }

  val isSimplexLink = this is SimplexLink

  companion object {
    val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline)
  }
}

@Serializable
enum class SimplexLinkType(val linkType: String) {
  contact("contact"),
  invitation("invitation"),
  group("group"),
  channel("channel"),
  relay("relay");

  val description: String get() = generalGetString(when (this) {
      contact -> MR.strings.simplex_link_contact
      invitation -> MR.strings.simplex_link_invitation
      group -> MR.strings.simplex_link_group
      channel -> MR.strings.simplex_link_channel
      relay -> MR.strings.simplex_link_relay
  })
}

@Serializable
enum class FormatColor(val color: String) {
  red("red"),
  green("green"),
  blue("blue"),
  yellow("yellow"),
  cyan("cyan"),
  magenta("magenta"),
  black("black"),
  white("white");

  val uiColor: Color @Composable get() = when (this) {
    red -> Color.Red
    green -> SimplexGreen
    blue -> SimplexBlue
    yellow -> WarningYellow
    cyan -> Color.Cyan
    magenta -> Color.Magenta
    black -> MaterialTheme.colors.onBackground
    white -> MaterialTheme.colors.onBackground
  }
}


@Serializable(with = ReportReasonSerializer::class)
sealed class ReportReason {
  @Serializable @SerialName("spam") object Spam: ReportReason()
  @Serializable @SerialName("illegal") object Illegal: ReportReason()
  @Serializable @SerialName("community") object Community: ReportReason()
  @Serializable @SerialName("profile") object Profile: ReportReason()
  @Serializable @SerialName("other") object Other: ReportReason()
  @Serializable @SerialName("unknown") data class Unknown(val type: String): ReportReason()

  companion object {
    val supportedReasons: List<ReportReason> = listOf(Spam, Illegal, Community, Profile, Other)
  }

  val text: String get() = when (this) {
    Spam -> generalGetString(MR.strings.report_reason_spam)
    Illegal -> generalGetString(MR.strings.report_reason_illegal)
    Community -> generalGetString(MR.strings.report_reason_community)
    Profile -> generalGetString(MR.strings.report_reason_profile)
    Other -> generalGetString(MR.strings.report_reason_other)
    is Unknown -> type
  }
}

object ReportReasonSerializer : KSerializer<ReportReason> {
  override val descriptor: SerialDescriptor =
    PrimitiveSerialDescriptor("ReportReason", PrimitiveKind.STRING)

  override fun deserialize(decoder: Decoder): ReportReason {
    return when (val value = decoder.decodeString()) {
      "spam" -> ReportReason.Spam
      "illegal" -> ReportReason.Illegal
      "community" -> ReportReason.Community
      "profile" -> ReportReason.Profile
      "other" -> ReportReason.Other
      else -> ReportReason.Unknown(value)
    }
  }

  override fun serialize(encoder: Encoder, value: ReportReason) {
    val stringValue = when (value) {
      is ReportReason.Spam -> "spam"
      is ReportReason.Illegal -> "illegal"
      is ReportReason.Community -> "community"
      is ReportReason.Profile -> "profile"
      is ReportReason.Other -> "other"
      is ReportReason.Unknown -> value.type
    }
    encoder.encodeString(stringValue)
  }
}

@Serializable
class SndFileTransfer() {}

@Serializable
data class RcvFileTransfer(
  val fileId: Long,
)

@Serializable
data class FileTransferMeta(
  val fileId: Long,
  val fileName: String,
  val filePath: String,
  val fileSize: Long,
)

@Serializable
enum class CICallStatus {
  @SerialName("pending") Pending,
  @SerialName("missed") Missed,
  @SerialName("rejected") Rejected,
  @SerialName("accepted") Accepted,
  @SerialName("negotiated") Negotiated,
  @SerialName("progress") Progress,
  @SerialName("ended") Ended,
  @SerialName("error") Error;

  fun text(sec: Int): String = when (this) {
    Pending -> generalGetString(MR.strings.callstatus_calling)
    Missed -> generalGetString(MR.strings.callstatus_missed)
    Rejected -> generalGetString(MR.strings.callstatus_rejected)
    Accepted -> generalGetString(MR.strings.callstatus_accepted)
    Negotiated -> generalGetString(MR.strings.callstatus_connecting)
    Progress -> generalGetString(MR.strings.callstatus_in_progress)
    Ended -> String.format(generalGetString(MR.strings.callstatus_ended), durationText(sec))
    Error -> generalGetString(MR.strings.callstatus_error)
  }
}

fun durationText(sec: Int): String {
  val s = sec % 60
  val m = sec / 60
  return if (m < 60) "%02d:%02d".format(m, s) else "%02d:%02d:%02d".format(m / 60, m % 60, s)
}

@Serializable
sealed class MsgErrorType() {
  @Serializable @SerialName("msgSkipped") class MsgSkipped(val fromMsgId: Long, val toMsgId: Long): MsgErrorType()
  @Serializable @SerialName("msgBadId") class MsgBadId(val msgId: Long): MsgErrorType()
  @Serializable @SerialName("msgBadHash") class MsgBadHash(): MsgErrorType()
  @Serializable @SerialName("msgDuplicate") class MsgDuplicate(): MsgErrorType()

  val text: String get() = when (this) {
    is MsgSkipped -> String.format(generalGetString(MR.strings.integrity_msg_skipped), toMsgId - fromMsgId + 1)
    is MsgBadHash -> generalGetString(MR.strings.integrity_msg_bad_hash) // not used now
    is MsgBadId -> generalGetString(MR.strings.integrity_msg_bad_id) // not used now
    is MsgDuplicate -> generalGetString(MR.strings.integrity_msg_duplicate) // not used now
  }
}

@Serializable
sealed class RcvDirectEvent() {
  @Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent()
  @Serializable @SerialName("profileUpdated") class ProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvDirectEvent()
  @Serializable @SerialName("groupInvLinkReceived") class GroupInvLinkReceived(val groupProfile: GroupProfile): RcvDirectEvent()

  val text: String get() = when (this) {
    is ContactDeleted -> generalGetString(MR.strings.rcv_direct_event_contact_deleted)
    is ProfileUpdated -> profileUpdatedText(fromProfile, toProfile)
    is GroupInvLinkReceived -> generalGetString(MR.strings.rcv_direct_event_group_inv_link_received).format(groupProfile.displayName)
  }

  private fun profileUpdatedText(from: Profile, to: Profile): String =
    when {
      to.displayName != from.displayName || to.fullName != from.fullName ->
        generalGetString(MR.strings.profile_update_event_contact_name_changed).format(from.profileViewName, to.profileViewName)

      to.image != from.image -> when (to.image) {
        null -> generalGetString(MR.strings.profile_update_event_removed_picture)
        else -> generalGetString(MR.strings.profile_update_event_set_new_picture)
      }

      to.contactLink != from.contactLink -> when (to.contactLink) {
        null -> generalGetString(MR.strings.profile_update_event_removed_address)
        else -> generalGetString(MR.strings.profile_update_event_set_new_address)
      }
      // shouldn't happen if backend correctly creates item; UI should be synchronized with backend
      else -> generalGetString(MR.strings.profile_update_event_updated_profile)
    }
}

@Serializable
sealed class RcvGroupEvent() {
  @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
  @Serializable @SerialName("memberConnected") class MemberConnected(): RcvGroupEvent()
  @Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
  @Serializable @SerialName("userAccepted") class UserAccepted(): RcvGroupEvent()
  @Serializable @SerialName("memberLeft") class MemberLeft(): RcvGroupEvent()
  @Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): RcvGroupEvent()
  @Serializable @SerialName("memberBlocked") class MemberBlocked(val groupMemberId: Long, val profile: Profile, val blocked: Boolean): RcvGroupEvent()
  @Serializable @SerialName("userRole") class UserRole(val role: GroupMemberRole): RcvGroupEvent()
  @Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
  @Serializable @SerialName("userDeleted") class UserDeleted(): RcvGroupEvent()
  @Serializable @SerialName("groupDeleted") class GroupDeleted(): RcvGroupEvent()
  @Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): RcvGroupEvent()
  @Serializable @SerialName("invitedViaGroupLink") class InvitedViaGroupLink(): RcvGroupEvent()
  @Serializable @SerialName("memberCreatedContact") class MemberCreatedContact(): RcvGroupEvent()
  @Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent()
  @Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent()

  val text: String get() = when (this) {
    is MemberAdded -> String.format(generalGetString(MR.strings.rcv_group_event_member_added), profile.profileViewName)
    is MemberConnected -> generalGetString(MR.strings.rcv_group_event_member_connected)
    is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName)
    is UserAccepted -> generalGetString(MR.strings.rcv_group_event_user_accepted)
    is MemberLeft -> generalGetString(MR.strings.rcv_group_event_member_left)
    is MemberRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_member_role), profile.profileViewName, role.text)
    is MemberBlocked -> if (blocked) {
      String.format(generalGetString(MR.strings.rcv_group_event_member_blocked), profile.profileViewName)
    } else {
      String.format(generalGetString(MR.strings.rcv_group_event_member_unblocked), profile.profileViewName)
    }
    is UserRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_your_role), role.text)
    is MemberDeleted -> String.format(generalGetString(MR.strings.rcv_group_event_member_deleted), profile.profileViewName)
    is UserDeleted -> generalGetString(MR.strings.rcv_group_event_user_deleted)
    is GroupDeleted -> generalGetString(MR.strings.rcv_group_event_group_deleted)
    is GroupUpdated -> generalGetString(MR.strings.rcv_group_event_updated_group_profile)
    is InvitedViaGroupLink -> generalGetString(MR.strings.rcv_group_event_invited_via_your_group_link)
    is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact)
    is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile)
    is NewMemberPendingReview -> generalGetString(MR.strings.rcv_group_event_new_member_pending_review)
  }

  private fun profileUpdatedText(from: Profile, to: Profile): String =
    when {
      to.displayName != from.displayName || to.fullName != from.fullName ->
        generalGetString(MR.strings.profile_update_event_member_name_changed).format(from.profileViewName, to.profileViewName)

      to.image != from.image -> when (to.image) {
        null -> generalGetString(MR.strings.profile_update_event_removed_picture)
        else -> generalGetString(MR.strings.profile_update_event_set_new_picture)
      }
      // shouldn't happen if backend correctly creates item; UI should be synchronized with backend
      else -> generalGetString(MR.strings.profile_update_event_updated_profile)
    }
}

@Serializable
sealed class SndGroupEvent() {
  @Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): SndGroupEvent()
  @Serializable @SerialName("userRole") class UserRole(val role: GroupMemberRole): SndGroupEvent()
  @Serializable @SerialName("memberBlocked") class MemberBlocked(val groupMemberId: Long, val profile: Profile, val blocked: Boolean): SndGroupEvent()
  @Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
  @Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent()
  @Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent()
  @Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
  @Serializable @SerialName("userPendingReview") class UserPendingReview(): SndGroupEvent()

  val text: String get() = when (this) {
    is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text)
    is UserRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_role_for_yourself), role.text)
    is MemberBlocked -> if (blocked) {
      String.format(generalGetString(MR.strings.snd_group_event_member_blocked), profile.profileViewName)
    } else {
      String.format(generalGetString(MR.strings.snd_group_event_member_unblocked), profile.profileViewName)
    }
    is MemberDeleted -> String.format(generalGetString(MR.strings.snd_group_event_member_deleted), profile.profileViewName)
    is UserLeft -> generalGetString(MR.strings.snd_group_event_user_left)
    is GroupUpdated -> generalGetString(MR.strings.snd_group_event_group_profile_updated)
    is MemberAccepted -> generalGetString(MR.strings.snd_group_event_member_accepted)
    is UserPendingReview -> generalGetString(MR.strings.snd_group_event_user_pending_review)
  }
}

@Serializable
sealed class RcvConnEvent {
  @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase): RcvConnEvent()
  @Serializable @SerialName("ratchetSync") class RatchetSync(val syncStatus: RatchetSyncState): RcvConnEvent()
  @Serializable @SerialName("verificationCodeReset") object VerificationCodeReset: RcvConnEvent()
  @Serializable @SerialName("pqEnabled") class PQEnabled(val enabled: Boolean): RcvConnEvent()

  val text: String get() = when (this) {
    is SwitchQueue -> when (phase) {
      SwitchPhase.Completed -> generalGetString(MR.strings.rcv_conn_event_switch_queue_phase_completed)
      else -> generalGetString(MR.strings.rcv_conn_event_switch_queue_phase_changing)
    }
    is RatchetSync -> ratchetSyncStatusToText(syncStatus)
    is VerificationCodeReset -> generalGetString(MR.strings.rcv_conn_event_verification_code_reset)
    is PQEnabled -> if (enabled) {
      generalGetString(MR.strings.conn_event_enabled_pq)
    } else {
      generalGetString(MR.strings.conn_event_disabled_pq)
    }
  }
}

fun ratchetSyncStatusToText(ratchetSyncStatus: RatchetSyncState): String {
  return when (ratchetSyncStatus) {
    RatchetSyncState.Ok -> generalGetString(MR.strings.conn_event_ratchet_sync_ok)
    RatchetSyncState.Allowed -> generalGetString(MR.strings.conn_event_ratchet_sync_allowed)
    RatchetSyncState.Required -> generalGetString(MR.strings.conn_event_ratchet_sync_required)
    RatchetSyncState.Started -> generalGetString(MR.strings.conn_event_ratchet_sync_started)
    RatchetSyncState.Agreed -> generalGetString(MR.strings.conn_event_ratchet_sync_agreed)
  }
}

@Serializable
sealed class SndConnEvent {
  @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase, val member: GroupMemberRef? = null): SndConnEvent()
  @Serializable @SerialName("ratchetSync") class RatchetSync(val syncStatus: RatchetSyncState, val member: GroupMemberRef? = null): SndConnEvent()
  @Serializable @SerialName("pqEnabled") class PQEnabled(val enabled: Boolean): SndConnEvent()

  val text: String
    get() = when (this) {
      is SwitchQueue -> {
        member?.profile?.profileViewName?.let {
          return when (phase) {
            SwitchPhase.Completed -> String.format(generalGetString(MR.strings.snd_conn_event_switch_queue_phase_completed_for_member), it)
            else -> String.format(generalGetString(MR.strings.snd_conn_event_switch_queue_phase_changing_for_member), it)
          }
        }
        when (phase) {
          SwitchPhase.Completed -> generalGetString(MR.strings.snd_conn_event_switch_queue_phase_completed)
          else -> generalGetString(MR.strings.snd_conn_event_switch_queue_phase_changing)
        }
      }

      is RatchetSync -> {
        member?.profile?.profileViewName?.let {
          return when (syncStatus) {
            RatchetSyncState.Ok -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_ok), it)
            RatchetSyncState.Allowed -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_allowed), it)
            RatchetSyncState.Required -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_required), it)
            RatchetSyncState.Started -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_started), it)
            RatchetSyncState.Agreed -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_agreed), it)
          }
        }
        ratchetSyncStatusToText(syncStatus)
      }

      is PQEnabled -> if (enabled) {
        generalGetString(MR.strings.conn_event_enabled_pq)
      } else {
        generalGetString(MR.strings.conn_event_disabled_pq)
      }
    }
}

@Serializable
enum class SwitchPhase {
  @SerialName("started") Started,
  @SerialName("confirmed") Confirmed,
  @SerialName("secured") Secured,
  @SerialName("completed") Completed
}

sealed class ChatItemTTL: Comparable<ChatItemTTL> {
  object Day: ChatItemTTL()
  object Week: ChatItemTTL()
  object Month: ChatItemTTL()
  object Year: ChatItemTTL()
  data class Seconds(val secs: Long): ChatItemTTL()
  object None: ChatItemTTL()

  override fun compareTo(other: ChatItemTTL): Int =
    (seconds.takeIf { it != 0L } ?: Long.MAX_VALUE)
      .compareTo(other.seconds.takeIf { it != 0L } ?: Long.MAX_VALUE)

  val seconds: Long
    get() =
      when (this) {
        is None -> 0
        is Day -> 86400L
        is Week -> 7 * 86400L
        is Month -> 30 * 86400L
        is Year -> 365 * 86400L
        is Seconds -> secs
      }

  val text: String
    get() = when(this) {
      is None -> generalGetString(MR.strings.chat_item_ttl_none)
      is Day -> generalGetString(MR.strings.chat_item_ttl_day)
      is Week -> generalGetString(MR.strings.chat_item_ttl_week)
      is Month -> generalGetString(MR.strings.chat_item_ttl_month)
      is Year -> generalGetString(MR.strings.chat_item_ttl_year)
      is Seconds -> String.format(generalGetString(MR.strings.chat_item_ttl_seconds), secs)
    }

  val neverExpires: Boolean get() = this is None

  companion object {
    fun fromSeconds(seconds: Long): ChatItemTTL =
      when (seconds) {
        0L -> None
        86400L -> Day
        7 * 86400L -> Week
        30 * 86400L -> Month
        365 * 86400L -> Year
        else -> Seconds(seconds)
      }
  }
}

@Serializable
data class ChatTag(
  val chatTagId: Long,
  val chatTagText: String,
  val chatTagEmoji: String?
)

@Serializable
class ChatItemInfo(
  val itemVersions: List<ChatItemVersion>,
  val memberDeliveryStatuses: List<MemberDeliveryStatus>?,
  val forwardedFromChatItem: AChatItem?
)

@Serializable
data class ChatItemVersion(
  val chatItemVersionId: Long,
  val msgContent: MsgContent,
  val formattedText: List<FormattedText>?,
  val itemVersionTs: Instant,
  val createdAt: Instant,
)

@Serializable
data class MemberDeliveryStatus(
  val groupMemberId: Long,
  val memberDeliveryStatus: GroupSndStatus,
  val sentViaProxy: Boolean?
)

enum class NotificationPreviewMode {
  MESSAGE, CONTACT, HIDDEN;

  companion object {
    val default: NotificationPreviewMode = MESSAGE
  }
}

data class RemoteCtrlSession(
  val ctrlAppInfo: CtrlAppInfo?,
  val appVersion: String,
  val sessionState: UIRemoteCtrlSessionState
) {
  val active: Boolean
    get () = sessionState is UIRemoteCtrlSessionState.Connected

  val sessionCode: String?
    get() = when (val s = sessionState) {
      is UIRemoteCtrlSessionState.PendingConfirmation -> s.sessionCode
      is UIRemoteCtrlSessionState.Connected -> s.sessionCode
      else -> null
    }
}

@Serializable
sealed class RemoteCtrlSessionState {
  @Serializable @SerialName("starting") object Starting: RemoteCtrlSessionState()
  @Serializable @SerialName("searching") object Searching: RemoteCtrlSessionState()
  @Serializable @SerialName("connecting") object Connecting: RemoteCtrlSessionState()
  @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val sessionCode: String): RemoteCtrlSessionState()
  @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState()
}

@Serializable
sealed class RemoteCtrlStopReason {
  @Serializable @SerialName("discoveryFailed") class DiscoveryFailed(val chatError: ChatError): RemoteCtrlStopReason()
  @Serializable @SerialName("connectionFailed") class ConnectionFailed(val chatError: ChatError): RemoteCtrlStopReason()
  @Serializable @SerialName("setupFailed") class SetupFailed(val chatError: ChatError): RemoteCtrlStopReason()
  @Serializable @SerialName("disconnected") object Disconnected: RemoteCtrlStopReason()
}

sealed class UIRemoteCtrlSessionState {
  object Starting: UIRemoteCtrlSessionState()
  object Searching: UIRemoteCtrlSessionState()
  data class Found(val remoteCtrl: RemoteCtrlInfo, val compatible: Boolean): UIRemoteCtrlSessionState()
  data class Connecting(val remoteCtrl_: RemoteCtrlInfo? = null): UIRemoteCtrlSessionState()
  data class PendingConfirmation(val remoteCtrl_: RemoteCtrlInfo? = null, val sessionCode: String): UIRemoteCtrlSessionState()
  data class Connected(val remoteCtrl: RemoteCtrlInfo, val sessionCode: String): UIRemoteCtrlSessionState()
}
